From c709a92ee8cd4e8a832ced0b44207c9a3cd6948e Mon Sep 17 00:00:00 2001 From: scgopi Date: Fri, 4 Sep 2026 21:08:13 -0700 Subject: [PATCH 1/6] Rename Artifactory to Mailroom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Artifactory named the feature after the JFrog instance the OpenAI agents improvised a board inside, in the Hugging Face incident it was drawn from. The allusion does not survive contact with a reader: the vendor was the least memorable detail of that story (the coverage almost uniformly calls it "a secret message board"), while JFrog owns the word for every developer with a build pipeline, and predicts a binary repository — the one thing this is not. Mailroom is what the section actually is now that it carries both halves of a graph's traffic: addressed correspondence (node send, message edges, handoffs) and unaddressed notices left for whoever comes next. A mailroom is shared infrastructure, which is the right scope — inbox and mailbox are per-recipient, and this is one board per graph. Board was unavailable: SummaryBoard/BoardNode/BoardForm already mean the loop's diagram. Blanket identifier rename; compatibility follows in the next commits. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AtVtkzZNE5fLPeHVqM7A3K --- .../Sources/CLI/GraphcodeCommand.swift | 134 +++++------ .../Sources/Domain/GraphcodeSettings.swift | 18 +- GraphcodeKit/Sources/Domain/LoopGraph.swift | 14 +- GraphcodeKit/Sources/Domain/LoopNode.swift | 32 +-- .../Sources/Domain/SessionBriefing.swift | 22 +- GraphcodeKit/Sources/GraphStore.swift | 156 ++++++------ GraphcodeKit/Sources/IPC/DaemonProtocol.swift | 22 +- GraphcodeKit/Sources/ProjectRegistry.swift | 4 +- .../Sources/Sessions/MessageBus.swift | 12 +- .../Sources/Sessions/NodeMemory.swift | 10 +- .../Sources/Sessions/RemoteGraphAccess.swift | 106 ++++----- .../Sources/Sessions/ZmxSessionLauncher.swift | 2 +- .../Sources/Templates/StarterTemplates.swift | 10 +- .../Sources/Mailroom.swift | 30 +-- Package.swift | 10 +- Project.swift | 10 +- docs/ramps.json | 2 +- graphcode-cli/Sources/main.swift | 54 ++--- graphcode/Sources/Clients/FeatureRamps.swift | 4 +- .../App/AppFeature+LoopSessions.swift | 4 +- .../Sources/Features/App/AppFeature.swift | 4 +- .../LoopWorkspace/LoopWorkspaceFeature.swift | 26 +- .../LoopWorkspace/LoopWorkspaceRail.swift | 60 ++--- .../LoopWorkspace/LoopWorkspaceView.swift | 14 +- ...orySection.swift => MailroomSection.swift} | 46 ++-- .../Features/Settings/SettingsModel.swift | 40 ++-- .../Features/Settings/SettingsView.swift | 6 +- graphcode/Tests/FeatureRampsTests.swift | 16 +- ...tTests.swift => MailroomBudgetTests.swift} | 100 ++++---- ...Tests.swift => MailroomCommandTests.swift} | 204 ++++++++-------- ...sts.swift => MailroomRailShareTests.swift} | 14 +- ...wift => MailroomSinceYouLookedTests.swift} | 36 +-- ...factoryTests.swift => MailroomTests.swift} | 224 +++++++++--------- graphcode/Tests/RemoteCLIShimTests.swift | 176 +++++++------- ...ests.swift => SettingsMailroomTests.swift} | 20 +- graphcode/Tests/StarterTemplateTests.swift | 6 +- 36 files changed, 824 insertions(+), 824 deletions(-) rename ArtifactoryKit/Sources/Artifactory.swift => MailroomKit/Sources/Mailroom.swift (90%) rename graphcode/Sources/Features/LoopWorkspace/{ArtifactorySection.swift => MailroomSection.swift} (91%) rename graphcode/Tests/{ArtifactoryBudgetTests.swift => MailroomBudgetTests.swift} (73%) rename graphcode/Tests/{ArtifactoryCommandTests.swift => MailroomCommandTests.swift} (59%) rename graphcode/Tests/{ArtifactoryRailShareTests.swift => MailroomRailShareTests.swift} (69%) rename graphcode/Tests/{ArtifactorySinceYouLookedTests.swift => MailroomSinceYouLookedTests.swift} (73%) rename graphcode/Tests/{ArtifactoryTests.swift => MailroomTests.swift} (65%) rename graphcode/Tests/{SettingsArtifactoryTests.swift => SettingsMailroomTests.swift} (81%) diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index 66f32d0f..e274f32c 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -1,4 +1,4 @@ -import ArtifactoryKit +import MailroomKit import Foundation /// Argument parsing and output formatting for the `graphcode` CLI @@ -48,28 +48,28 @@ public enum GraphcodeCommand: Equatable, Sendable { case exportNode(projectPath: String, nodeID: UUID, output: String, includeChildren: Bool = false) case exportGraph(projectPath: String, output: String) case importNodes(projectPath: String, fromZip: String, asChildOf: UUID? = nil) - /// The Artifactory verbs (docs/03-architecture.md#cli-graphcode): the shared, + /// The Mailroom verbs (docs/03-architecture.md#cli-graphcode): the shared, /// unaddressed board any loop can write to and read. Attribution is not parsed — /// like `sendMessage`, the sender comes from `ZMX_SESSION` at execution. - case artifactoryPost(projectPath: String, topic: String?, text: String) + case mailroomPost(projectPath: String, topic: String?, text: String) /// Read unread, then mark the board read — the cursor belongs to the calling loop, /// so this verb only means anything run from inside a session. `--headlines` prints /// one triage line per unread post instead of full bodies (pair it with `read`); /// `--mark` advances the cursor without printing the backlog ("start me from now"); /// `--json` emits the unread posts machine-readably; `--full` insists on every body /// where the verb would otherwise triage a large backlog down to headlines itself. - case artifactorySync( + case mailroomSync( projectPath: String, headlines: Bool, mark: Bool, json: Bool, full: Bool) /// One post in full, by id — the deep-read half of `sync --headlines` triage. /// Read-only: the post is in the snapshot, no command reaches the daemon. - case artifactoryRead(projectPath: String, postID: Int) + case mailroomRead(projectPath: String, postID: Int) /// The whole board, read-only: no command reaches the daemon, no cursor moves. /// `--search` filters by substring across author/topic/body; `--json` emits the /// board machine-readably. - case artifactoryList(projectPath: String, search: String?, json: Bool) + case mailroomList(projectPath: String, search: String?, json: Bool) /// Subscribe (`on: true`, `--topic` filters) or unsubscribe (`--off`) the calling /// loop; like `sync`, the subscription belongs to a loop, not a shell. - case artifactoryWatch(projectPath: String, on: Bool, topic: String?) + case mailroomWatch(projectPath: String, on: Bool, topic: String?) public enum ParseError: Error, Equatable { case unknownCommand(String) @@ -101,9 +101,9 @@ public enum GraphcodeCommand: Equatable, Sendable { graphcode node pilot dry-run a composite graphcode node arm arm it (needs a pilot first) graphcode edge create [--kind ] [--condition ] - graphcode artifactory post [--topic ] + graphcode mailroom post [--topic ] leave a note on the shared board for whoever comes next - graphcode artifactory sync [--headlines] [--full] [--mark] [--json] + graphcode mailroom sync [--headlines] [--full] [--mark] [--json] read your unread posts and mark the board read. A large backlog prints as headlines on its own and says so — --full insists on every body, --headlines insists on @@ -112,12 +112,12 @@ public enum GraphcodeCommand: Equatable, Sendable { the machine-readable shape. Combined, the output wins in the order --json > --mark > --headlines > --full; the cursor advances whichever flags you pass - graphcode artifactory read + graphcode mailroom read one post in full — the deep-read half of --headlines - graphcode artifactory list [--search ] [--json] + graphcode mailroom list [--search ] [--json] the whole board, read-only — no cursor moves; --search filters by substring across author, topic and body - graphcode artifactory watch [--topic ] [--off] + graphcode mailroom watch [--topic ] [--off] have matching posts typed into this loop's session as they land; --off stops watching graphcode usage @@ -227,9 +227,9 @@ public enum GraphcodeCommand: Equatable, Sendable { --into spawn into a different project (--kind spawn only); this is how the global graph dispatches work into a project - ARTIFACTORY + MAILROOM The shared, unaddressed board: `node send` reaches one peer you already know; - an Artifactory post is a note for whoever comes next, discoverable by loops that + an Mailroom post is a note for whoever comes next, discoverable by loops that did not exist when it was written. Run from inside a loop, posts are attributed to that loop (`ZMX_SESSION`, the same mechanism as `node send`); from a human's shell they read as from "a human". `sync` and `watch` need that loop identity — @@ -255,9 +255,9 @@ public enum GraphcodeCommand: Equatable, Sendable { graphcode status graphcode node send --follow-up stage work without interrupting an active turn - graphcode artifactory sync + graphcode mailroom sync check what other loops left for you before starting a pass - graphcode artifactory post --topic claims issue #12 is mine + graphcode mailroom post --topic claims issue #12 is mine stake a claim where every loop will find it, addressed to no one graphcode node pilot graphcode node arm @@ -402,8 +402,8 @@ public enum GraphcodeCommand: Equatable, Sendable { throw ParseError.unknownCommand("node \(verb)") } - case "artifactory": - return try parseArtifactory(&arguments) + case "mailroom": + return try parseMailroom(&arguments) case "edge": let verb = try take(&arguments, name: "edge subcommand") @@ -768,7 +768,7 @@ extension GraphcodeCommand { /// every other subcommand takes as input — a truncated id would look tidier and be /// useless. public static func render( - _ graph: LoopGraph, artifactoryReader readerID: UUID? = nil + _ graph: LoopGraph, mailroomReader readerID: UUID? = nil ) -> String { var lines = ["\(graph.project.name) (\(graph.aggregateState))"] if graph.nodes.isEmpty { @@ -776,7 +776,7 @@ extension GraphcodeCommand { // The board can outlive every loop on it — human posts carry no authorID, so // "last loop deleted" does not mean "board empty". The line belongs on this // path too, not only on the rendered-below one. - if let boardLine = renderArtifactoryStatusLine(graph, readerID: readerID) { + if let boardLine = renderMailroomStatusLine(graph, readerID: readerID) { lines.append(" \(boardLine)") } return lines.joined(separator: "\n") @@ -808,8 +808,8 @@ extension GraphcodeCommand { } } // The board rides last: one line, only when there is anything on it, so a - // project that never touched the Artifactory renders as it always did. - if let boardLine = renderArtifactoryStatusLine(graph, readerID: readerID) { + // project that never touched the Mailroom renders as it always did. + if let boardLine = renderMailroomStatusLine(graph, readerID: readerID) { lines.append(" \(boardLine)") } return lines.joined(separator: "\n") @@ -847,25 +847,25 @@ extension GraphcodeCommand { return projects.map { "\($0.name) \($0.path)" }.joined(separator: "\n") } - /// The board for a terminal. `artifactory list` prints the whole thing (`reader` - /// nil); `artifactory sync` passes the reading loop's id and prints only what its - /// cursor has not covered — the subtraction is `Artifactory.unread`, the arithmetic + /// The board for a terminal. `mailroom list` prints the whole thing (`reader` + /// nil); `mailroom sync` 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 `artifactory read `); `search` keeps only posts whose author, + /// deep read is `mailroom 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. - public static func renderArtifactory( + public static func renderMailroom( _ graph: LoopGraph, unreadFor readerID: UUID? = nil, headlines: Bool = false, search: String? = nil, autoTriage: Bool = false ) -> String { - var posts: [ArtifactoryPost] + var posts: [MailroomPost] if let readerID { - posts = Artifactory.unread( - in: graph.artifactory, since: graph.nodes[id: readerID]?.lastArtifactoryRead) + posts = Mailroom.unread( + in: graph.mailroom, since: graph.nodes[id: readerID]?.lastMailroomRead) } else { - posts = graph.artifactory + posts = graph.mailroom } if let search, !search.isEmpty { let needle = search.lowercased() @@ -881,20 +881,20 @@ extension GraphcodeCommand { : "no unread posts match '\(search)'" } return readerID == nil - ? "the board is empty — post one: graphcode artifactory post " + ? "the board is empty — post one: graphcode mailroom post " : "no unread posts" } // `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 && Artifactory.needsTriage(posts) - let label = readerID == nil ? "artifactory" : "artifactory, unread" + 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")" if triaged { header += " — headlines only, that is a lot to read at once. " - + "Full text: graphcode artifactory read \(graph.project.path) " + + "Full text: graphcode mailroom read \(graph.project.path) " } var lines = [header] for post in posts { @@ -903,25 +903,25 @@ extension GraphcodeCommand { return lines.joined(separator: "\n") } - /// The board as one machine-readable object — the same posts `renderArtifactory` + /// 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 renderArtifactoryJSON( + public static func renderMailroomJSON( _ graph: LoopGraph, unreadFor readerID: UUID? = nil, search: String? = nil ) -> String { struct Board: Encodable { - var posts: [ArtifactoryPost] + var posts: [MailroomPost] var lastRead: Int? } - var posts: [ArtifactoryPost] + var posts: [MailroomPost] if let readerID { - posts = Artifactory.unread( - in: graph.artifactory, since: graph.nodes[id: readerID]?.lastArtifactoryRead) + posts = Mailroom.unread( + in: graph.mailroom, since: graph.nodes[id: readerID]?.lastMailroomRead) } else { - posts = graph.artifactory + posts = graph.mailroom } if let search, !search.isEmpty { let needle = search.lowercased() @@ -930,7 +930,7 @@ extension GraphcodeCommand { || $0.topic?.lowercased().contains(needle) == true } } - let lastRead = readerID.flatMap { graph.nodes[id: $0]?.lastArtifactoryRead } + let lastRead = readerID.flatMap { graph.nodes[id: $0]?.lastMailroomRead } let board = Board(posts: posts, lastRead: lastRead) let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] @@ -941,44 +941,44 @@ extension GraphcodeCommand { /// `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 Artifactory renders exactly + /// board 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. - public static func renderArtifactoryStatusLine( + public static func renderMailroomStatusLine( _ graph: LoopGraph, readerID: UUID? = nil ) -> String? { - guard !graph.artifactory.isEmpty else { return nil } - let total = graph.artifactory.count + 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 // either — a foreign or stale id gets the plain count, same as a human. guard let readerID, graph.nodes[id: readerID] != nil else { - return "artifactory: \(total) post\(plural)" + return "mailroom: \(total) post\(plural)" } - let unread = Artifactory.unread( - in: graph.artifactory, since: graph.nodes[id: readerID]?.lastArtifactoryRead + let unread = Mailroom.unread( + in: graph.mailroom, since: graph.nodes[id: readerID]?.lastMailroomRead ).count - return "artifactory: \(total) post\(plural), \(unread) unread for you" + return "mailroom: \(total) post\(plural), \(unread) unread for you" } /// One post, one line — the same identification the daemon's wake nudge quotes, so /// a loop reads a note the same way everywhere it meets one. - public static func render(_ post: ArtifactoryPost) -> String { + public static func render(_ post: MailroomPost) -> String { let topic = post.topic.map { " (\($0))" } ?? "" // `Date.formatted` has no precedent in GraphcodeKit and corelibs-foundation's // FormatStyle support has been uneven across the toolchains the Linux CI runs; // a fixed DateFormatter is the boring, portable answer. - let stamp = ArtifactoryPost.stampFormat.string(from: post.at) + let stamp = MailroomPost.stampFormat.string(from: post.at) return "#\(post.id)\(topic) from \(post.author) at \(stamp) — \(post.body)" } /// The triage line — everything `render` says about a post's identity, with the - /// body cut to a glance. The pair (`sync --headlines`, `artifactory read `) is + /// body cut to a glance. The pair (`sync --headlines`, `mailroom read `) is /// how a loop joining after forty messages spends forty lines instead of forty /// kilobytes, and deep-reads only the posts that turned out to matter. - public static func renderHeadline(_ post: ArtifactoryPost) -> String { + public static func renderHeadline(_ post: MailroomPost) -> String { // Bodies are single-line at the daemon (memos flatten), but this renders a // *rendered line*, and the one-triage-line promise survives anything. let full = render(post).replacingOccurrences(of: "\n", with: " ") @@ -987,10 +987,10 @@ extension GraphcodeCommand { return String(full.prefix(budget)) + "…" } - /// `artifactory post`'s answer — the sequence number is what the author's own log and + /// `mailroom 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.artifactory.last else { return "posted" } + guard let post = graph.mailroom.last else { return "posted" } let topic = post.topic.map { " (\($0))" } ?? "" return "posted #\(post.id)\(topic)" } @@ -1094,14 +1094,14 @@ extension GraphcodeCommand { return .importNodes(projectPath: projectPath, fromZip: zipPath, asChildOf: asChildOf) } - /// The `artifactory` verbs' parsing, split from `parseVerb` the way export/import + /// The `mailroom` verbs' parsing, split from `parseVerb` the way export/import /// were. The note is joined argv words — the `node send`/`node memo` bargain, so - /// `graphcode artifactory post --topic claims issue #12 is mine` needs no + /// `graphcode mailroom post --topic claims issue #12 is mine` needs no /// quoting gymnastics — with `--topic ` riding along in either position. - fileprivate static func parseArtifactory( + fileprivate static func parseMailroom( _ arguments: inout [String] ) throws -> GraphcodeCommand { - let verb = try take(&arguments, name: "artifactory subcommand") + let verb = try take(&arguments, name: "mailroom subcommand") let path = try take(&arguments, name: "project-path") if arguments.contains(where: isHelpFlag) { throw HelpRequested() } switch verb { @@ -1117,12 +1117,12 @@ extension GraphcodeCommand { } let text = words.joined(separator: " ").trimmingCharacters(in: .whitespaces) guard !text.isEmpty else { throw ParseError.missingArgument("note") } - return .artifactoryPost(projectPath: path, topic: flags["topic"], text: text) + return .mailroomPost(projectPath: path, topic: flags["topic"], text: text) case "sync": try validateFlags(arguments, allowed: ["headlines", "mark", "json", "full"]) let flags = parseFlags(arguments) - return .artifactorySync( + return .mailroomSync( projectPath: path, headlines: flags["headlines"] != nil, mark: flags["mark"] != nil, json: flags["json"] != nil, full: flags["full"] != nil) @@ -1134,21 +1134,21 @@ extension GraphcodeCommand { guard let postID = Int(raw), postID >= 1 else { throw ParseError.invalidValue(argument: "post-id", value: raw) } - return .artifactoryRead(projectPath: path, postID: postID) + return .mailroomRead(projectPath: path, postID: postID) case "list": try validateFlags(arguments, allowed: ["search", "json"]) let flags = parseFlags(arguments) - return .artifactoryList( + return .mailroomList( projectPath: path, search: flags["search"], json: flags["json"] != nil) case "watch": try validateFlags(arguments, allowed: ["topic", "off"]) let flags = parseFlags(arguments) - return .artifactoryWatch(projectPath: path, on: flags["off"] == nil, topic: flags["topic"]) + return .mailroomWatch(projectPath: path, on: flags["off"] == nil, topic: flags["topic"]) default: - throw ParseError.unknownCommand("artifactory \(verb)") + throw ParseError.unknownCommand("mailroom \(verb)") } } } diff --git a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift index af4e3520..7c6c7d96 100644 --- a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift +++ b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift @@ -362,20 +362,20 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { /// heartbeat loops immediately without restarting anything. public var daemonHeartbeatEnabled: Bool - /// Whether loops get the **Artifactory** — a shared, unaddressed message board the + /// Whether loops get the **Mailroom** — a shared, unaddressed message board the /// graph's loops post to and read without any wiring: `node send` and edges are - /// for talking to a peer you already know, while the Artifactory is the ambient + /// for talking to a peer you already know, while the Mailroom is the ambient /// counterpart, a note dropped for whoever comes next (a decision, a dead end, a /// claim on a task), discoverable by loops that did not exist when it was written. /// /// **Off by default, and beta-ramped.** The app resolves - /// `FeatureRamps.Feature.artifactory` — beta installs first, stable only when the + /// `FeatureRamps.Feature.mailroom` — beta installs first, stable only when the /// ramp says so — and writes the resolved value here, which is the bit the daemon - /// (which cannot see ramps or `UserDefaults`) actually enforces: every `artifactory` + /// (which cannot see ramps or `UserDefaults`) actually enforces: every `mailroom` /// command, the briefing's board section, and the wake digest's pointer all read /// this. A flip the human made in Settings is a recorded choice, preserved the way /// `summarisesLoops`' is. - public var artifactoryEnabled: Bool + public var mailroomEnabled: Bool /// Whether `graphcoded` keeps the Mac awake while any loop is running /// (`AwakeAssertion`). Off by default and deliberately so: a background process that @@ -404,7 +404,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { summaryUsesModel: Bool = false, visualisesSummaries: Bool = false, daemonHeartbeatEnabled: Bool = false, - artifactoryEnabled: Bool = true, + mailroomEnabled: Bool = true, keepsMacAwakeWhileLoopsRun: Bool = false, worktreePolicies: [String: WorktreeHygienePolicy] = [:] ) { @@ -420,7 +420,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { self.summaryUsesModel = summaryUsesModel self.visualisesSummaries = visualisesSummaries self.daemonHeartbeatEnabled = daemonHeartbeatEnabled - self.artifactoryEnabled = artifactoryEnabled + self.mailroomEnabled = mailroomEnabled self.keepsMacAwakeWhileLoopsRun = keepsMacAwakeWhileLoopsRun self.worktreePolicies = worktreePolicies } @@ -474,8 +474,8 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { // beta-only — "no app has spoken yet" — and now means the default: a CLI-only // machine or a hand-edited file gets the board the way every install does, and // the app still writes an explicit value the moment a human flips the switch. - artifactoryEnabled = - try container.decodeIfPresent(Bool.self, forKey: .artifactoryEnabled) ?? true + mailroomEnabled = + try container.decodeIfPresent(Bool.self, forKey: .mailroomEnabled) ?? true // Absent means nobody has asked for it, which is the default. An update must never // start holding a power assertion on a machine whose owner did not choose that. keepsMacAwakeWhileLoopsRun = diff --git a/GraphcodeKit/Sources/Domain/LoopGraph.swift b/GraphcodeKit/Sources/Domain/LoopGraph.swift index 0a5ed908..161c525c 100644 --- a/GraphcodeKit/Sources/Domain/LoopGraph.swift +++ b/GraphcodeKit/Sources/Domain/LoopGraph.swift @@ -1,4 +1,4 @@ -import ArtifactoryKit +import MailroomKit import Foundation import IdentifiedCollections @@ -20,16 +20,16 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { public var scope: LoopGraphScope public var nodes: IdentifiedArrayOf public var edges: IdentifiedArrayOf - /// The project's Artifactory — every post any loop has dropped onto the shared board, + /// 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 - /// (`Artifactory.maxNotes`, `Artifactory.maxRecords`). Kept on the graph rather than in a + /// (`Mailroom.maxNotes`, `Mailroom.maxRecords`). 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. - public var artifactory: [ArtifactoryPost] = [] + public var mailroom: [MailroomPost] = [] public var project: ProjectRef { get { scope.projectRef } @@ -254,7 +254,7 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { // MARK: - Coding private enum CodingKeys: String, CodingKey { - case id, nodes, edges, artifactory + case id, nodes, edges, mailroom /// 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 @@ -269,7 +269,7 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { scope = LoopGraphScope(projectPath: ref.path, name: ref.name) nodes = try container.decodeIfPresent(IdentifiedArrayOf.self, forKey: .nodes) ?? [] edges = try container.decodeIfPresent(IdentifiedArrayOf.self, forKey: .edges) ?? [] - artifactory = try container.decodeIfPresent([ArtifactoryPost].self, forKey: .artifactory) ?? [] + mailroom = try container.decodeIfPresent([MailroomPost].self, forKey: .mailroom) ?? [] } public func encode(to encoder: Encoder) throws { @@ -280,6 +280,6 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { try container.encode(edges, forKey: .edges) // 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 !artifactory.isEmpty { try container.encode(artifactory, forKey: .artifactory) } + if !mailroom.isEmpty { try container.encode(mailroom, forKey: .mailroom) } } } diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index ec9427b6..187462b5 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -1,4 +1,4 @@ -import ArtifactoryKit +import MailroomKit import Foundation /// One node in a graph of loops: a unit of agentic work with a well-defined hand-off @@ -173,18 +173,18 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// The template a **timed or composite** loop still follows — see `TemplateFollow` /// for why only those two types do. `nil` for every snapshot loop. public var templateFollow: TemplateFollow? - /// The newest Artifactory post this loop has read — `ArtifactoryPost.id` of the last - /// post a `graphcode artifactory sync` showed it. `nil` has not synced yet and makes + /// The newest Mailroom post this loop has read — `MailroomPost.id` of the last + /// post a `graphcode mailroom sync` showed it. `nil` has not synced yet and makes /// every post unread; the cursor only moves through sync, so a loop that ignores /// the board accrues nothing but a number, and a loop that died with unread mail /// finds it still waiting at the next wake. - public var lastArtifactoryRead: Int? - /// This loop's standing subscription to its project's Artifactory — set and cleared - /// with `graphcode artifactory watch`. Non-nil means every matching post also gets + public var lastMailroomRead: Int? + /// This loop's standing subscription to its project's Mailroom — set and cleared + /// with `graphcode mailroom watch`. Non-nil means every matching post also gets /// delivered to this loop the way a `--follow-up` message is: typed into a live /// idle session, staged to a busy one's memory, waiting in the post itself for a /// loop that is gone. The post is the durable half; this is only the ding. - public var artifactoryWatch: ArtifactoryWatch? + public var mailroomWatch: MailroomWatch? /// Why the loop is `.stalled`, when the graph knows. A budget exhaustion and a stall /// bound both land in the same terminal state, and both wrote their reason only to /// the loop's memory log — every surface then showed a bare STALLED and a human had @@ -219,8 +219,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { createdBy: UUID? = nil, createdFromTemplateID: UUID? = nil, templateFollow: TemplateFollow? = nil, - lastArtifactoryRead: Int? = nil, - artifactoryWatch: ArtifactoryWatch? = nil, + lastMailroomRead: Int? = nil, + mailroomWatch: MailroomWatch? = nil, stallReason: String? = nil, state: LoopState = .idle, createdAt: Date = Date() @@ -248,8 +248,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { self.createdBy = createdBy self.createdFromTemplateID = createdFromTemplateID self.templateFollow = templateFollow - self.lastArtifactoryRead = lastArtifactoryRead - self.artifactoryWatch = artifactoryWatch + self.lastMailroomRead = lastMailroomRead + self.mailroomWatch = mailroomWatch self.stallReason = stallReason self.state = state self.createdAt = createdAt @@ -489,7 +489,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { private enum CodingKeys: String, CodingKey { case id, title, loopType, checkDescription, triggerPrompt, goal, backend, modelTier case worktreeBinding, subGraph, pilotState, usage, metricHistory, createdBy - case lastArtifactoryRead, artifactoryWatch + case lastMailroomRead, mailroomWatch case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly case summary, board, heartbeatIntervalSeconds, stallReason case createdFromTemplateID, templateFollow, sessionRestarts @@ -539,11 +539,11 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { // snapshots, which is what nil says. templateFollow = try container.decodeIfPresent( TemplateFollow.self, forKey: .templateFollow) - // Absent from graphs saved before the Artifactory existed — every loop simply has + // Absent from graphs saved before the Mailroom existed — every loop simply has // not read anything yet, which is what `nil` says. - lastArtifactoryRead = try container.decodeIfPresent(Int.self, forKey: .lastArtifactoryRead) - artifactoryWatch = try container.decodeIfPresent( - ArtifactoryWatch.self, forKey: .artifactoryWatch) + lastMailroomRead = try container.decodeIfPresent(Int.self, forKey: .lastMailroomRead) + mailroomWatch = try container.decodeIfPresent( + MailroomWatch.self, forKey: .mailroomWatch) stallReason = try container.decodeIfPresent(String.self, forKey: .stallReason) state = try container.decodeIfPresent(LoopState.self, forKey: .state) ?? .idle createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() diff --git a/GraphcodeKit/Sources/Domain/SessionBriefing.swift b/GraphcodeKit/Sources/Domain/SessionBriefing.swift index 3b9755d9..41bb5e70 100644 --- a/GraphcodeKit/Sources/Domain/SessionBriefing.swift +++ b/GraphcodeKit/Sources/Domain/SessionBriefing.swift @@ -77,30 +77,30 @@ public enum SessionBriefing { Do not reach for this for one-off work: "check the build" is a goal, "check the build every hour" is time-based. """ - // The Artifactory's section exists only while the beta ramp has the feature on: a + // The Mailroom's section exists only while the beta ramp has the feature on: a // briefing that taught verbs the daemon would refuse would send every loop // through a refusal once per idea. It interpolates inline after the "one-off." // sentence (the value leading with blank lines) so that off — an empty value — // leaves the briefing byte-for-byte what it was before this section existed. - let artifactorySection = - settings.artifactoryEnabled + let mailroomSection = + settings.mailroomEnabled ? """ - ## The Artifactory — notes for whoever comes next + ## The Mailroom — notes for whoever comes next - `node send` reaches one peer you already know. The Artifactory is the shared + `node send` reaches one peer you already know. The Mailroom is the shared counterpart: an unaddressed board any loop can post to and any loop can read, with no wiring and no ids — post for *whoever comes next*, including loops that do not exist yet. Check it at the start of a pass; post the moment you learn something a peer or successor should not have to rediscover: ```sh - graphcode artifactory sync \(projectPath) # read what you have not seen, mark it read - graphcode artifactory read \(projectPath) # one post in full - graphcode artifactory post \(projectPath) [--topic ] # leave something behind - graphcode artifactory list \(projectPath) # read-only peek, cursor untouched - graphcode artifactory watch \(projectPath) [--topic ] # ring me when new mail lands + graphcode mailroom sync \(projectPath) # read what you have not seen, mark it read + graphcode mailroom read \(projectPath) # one post in full + graphcode mailroom post \(projectPath) [--topic ] # leave something behind + graphcode mailroom list \(projectPath) # read-only peek, cursor untouched + graphcode mailroom watch \(projectPath) [--topic ] # ring me when new mail lands ``` Post decisions made, dead ends hit, claims staked ("I'm taking issue #12") — @@ -191,7 +191,7 @@ public enum SessionBriefing { the exact command for reporting results back to it. For recurring communication, an edge is still the right tool: a `message` edge fires automatically when you finish, a `handoff` sequences the other loop after you. This command is the - one-off.\(artifactorySection) + one-off.\(mailroomSection) ## Remembering across passes diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index 565cb015..3fa7037a 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -1,4 +1,4 @@ -import ArtifactoryKit +import MailroomKit import Foundation /// Owns the daemon's one `LoopGraph`, applies commands, automatically fires `.handoff` @@ -97,11 +97,11 @@ public actor GraphStore { /// switching it off empties the boards on the next poll without restarting anything, the /// same contract `onHeartbeatEnabled` has. private let onBoardsEnabled: (@Sendable () -> Bool)? - /// Whether the Artifactory is on — read fresh at every gate — so flipping the Settings + /// Whether the Mailroom is on — read fresh at every gate — so flipping the Settings /// toggle (or the beta ramp resolving) applies to the next post without restarting /// anything. `nil` (tests that don't care, and any client that never wires it) means /// off, which is the ramp's default. - private let onArtifactoryEnabled: (@Sendable () -> Bool)? + private let onMailroomEnabled: (@Sendable () -> Bool)? /// The newest pass each node has already been *asked* about, drawn or not. /// /// Without this, `NONE` — the answer the composer is told to give for a thin pass, and @@ -237,7 +237,7 @@ public actor GraphStore { )? = nil, onBoardsEnabled: (@Sendable () -> Bool)? = nil, onResolveTemplate: (@Sendable (UUID, String?) -> PromptTemplate?)? = nil, - onArtifactoryEnabled: (@Sendable () -> Bool)? = nil, + onMailroomEnabled: (@Sendable () -> Bool)? = nil, goalCache: GoalEvaluationCache? = nil, recurrence: RecurrenceSink? = nil, subGraphDepth: Int = 0 @@ -268,7 +268,7 @@ public actor GraphStore { self.onComposeBoard = onComposeBoard self.onBoardsEnabled = onBoardsEnabled self.onResolveTemplate = onResolveTemplate - self.onArtifactoryEnabled = onArtifactoryEnabled + self.onMailroomEnabled = onMailroomEnabled self.goalCache = goalCache ?? GoalEvaluationCache() self.recurrence = recurrence } @@ -640,14 +640,14 @@ public actor GraphStore { case .messageNode(let nodeID, let text, let from, let followUp): await deliverAdHocMessage(to: nodeID, text: text, from: from, followUp: followUp ?? false) - case .artifactoryPost(let text, let topic, let from): - await artifactoryPost(text: text, topic: topic, from: from) + case .mailroomPost(let text, let topic, let from): + await mailroomPost(text: text, topic: topic, from: from) - case .artifactorySync(let from): - artifactorySync(from: from) + case .mailroomSync(let from): + mailroomSync(from: from) - case .artifactoryWatch(let on, let topic, let from): - artifactoryWatch(on: on, topic: topic, from: from) + case .mailroomWatch(let on, let topic, let from): + mailroomWatch(on: on, topic: topic, from: from) case .renameNode(let nodeID, let title): renameNode(nodeID, to: title) @@ -821,7 +821,7 @@ public actor GraphStore { // to prevent, and worker communication should mirror to the sub-graph's board // the way any other loop's does. nil still means off (the ramp's default), // which is why forwarding, not a nil-means-on reading, is the fix. - onArtifactoryEnabled: onArtifactoryEnabled, + onMailroomEnabled: onMailroomEnabled, goalCache: goalCache, recurrence: effects.recurrence, subGraphDepth: subGraphDepth + 1) @@ -1570,34 +1570,34 @@ public actor GraphStore { recordMemory(nodeID, "playbook rolled back\(sender.map { " by \($0)" } ?? "")") } - // MARK: - Artifactory + // MARK: - Mailroom - /// Whether the Artifactory is on, asked fresh at every gate with the refusal said out + /// Whether the Mailroom is on, asked fresh at every gate with the refusal said out /// loud — the export precedent: a beta-ramped feature a loop reaches for while the /// ramp has it off must answer with the way to turn it on, because the sender cannot /// tell a silent no-op from a board nobody read. - private func artifactoryIsOn() -> Bool { onArtifactoryEnabled?() == true } + private func mailroomIsOn() -> Bool { onMailroomEnabled?() == true } /// Drops a note onto the shared board. Unaddressed by design: there is no target /// id, no edge, no delivery guarantee to any *specific* loop — the post lands on /// the graph, watchers get their best-effort ding, and every future reader finds - /// it with one `artifactory sync`. - private func artifactoryPost(text: String, topic: String?, from senderID: UUID?) async { - guard artifactoryIsOn() else { + /// it with one `mailroom sync`. + private func mailroomPost(text: String, topic: String?, from senderID: UUID?) async { + guard mailroomIsOn() else { announceError( - "the Artifactory is off — enable Artifactory in Settings " - + "(artifactoryEnabled in ~/.graphcode/settings.json)") + "the Mailroom is off — enable Mailroom in Settings " + + "(mailroomEnabled in ~/.graphcode/settings.json)") return } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { - announceError("artifactory post refused: empty note") + announceError("mailroom post refused: empty note") return } - guard trimmed.utf8.count <= ArtifactoryPost.maxBodyBytes else { + guard trimmed.utf8.count <= MailroomPost.maxBodyBytes else { announceError( - "artifactory post refused: \(trimmed.utf8.count) bytes is over the " - + "\(ArtifactoryPost.maxBodyBytes)-byte bound — a post is a note to a peer, not " + "mailroom post refused: \(trimmed.utf8.count) bytes is over the " + + "\(MailroomPost.maxBodyBytes)-byte bound — a post is a note to a peer, not " + "a document; put the document in the repo and post the path") return } @@ -1605,12 +1605,12 @@ public actor GraphStore { topic.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } ?? Optional.none if let trimmedTopic, trimmedTopic.isEmpty { - announceError("artifactory post refused: an empty topic is no topic — omit it") + announceError("mailroom post refused: an empty topic is no topic — omit it") return } - guard trimmedTopic?.utf8.count ?? 0 <= ArtifactoryPost.maxTopicBytes else { + guard trimmedTopic?.utf8.count ?? 0 <= MailroomPost.maxTopicBytes else { announceError( - "artifactory post refused: topic over \(ArtifactoryPost.maxTopicBytes) bytes") + "mailroom post refused: topic over \(MailroomPost.maxTopicBytes) bytes") return } // A foreign loop's id (a sender from another graph, addressing this board @@ -1622,17 +1622,17 @@ public actor GraphStore { } else { author = senderID == nil ? "a human" : "an outside loop" } - let post = ArtifactoryPost( - id: Artifactory.nextID(after: graph.artifactory), at: Date(), authorID: senderID, + let post = MailroomPost( + id: Mailroom.nextID(after: graph.mailroom), at: Date(), authorID: senderID, author: author, topic: trimmedTopic, body: trimmed) - graph.artifactory = Artifactory.pruned(graph.artifactory + [post]) + graph.mailroom = Mailroom.pruned(graph.mailroom + [post]) // The author's own log keeps a line — their next pass should know what they // already told the board, so it doesn't re-announce it. if let senderID, graph.nodes[id: senderID] != nil { recordMemory( - senderID, "artifactory: posted #\(post.id)\(topicSuffix(post)) — \(post.body)") + senderID, "mailroom: posted #\(post.id)\(topicSuffix(post)) — \(post.body)") } - await wakeArtifactoryWatchers(about: post) + await wakeMailroomWatchers(about: post) } /// The mailbox's ring. Every watcher whose subscription matches hears the post the @@ -1641,25 +1641,25 @@ public actor GraphStore { /// the delivery rules and their staging guarantees are this store's, learned once. /// The sender id stays `nil` on purpose: the wake names the *post's* author in its /// text, and a watcher reading it later must not mistake the ding for the mail. - private func wakeArtifactoryWatchers(about post: ArtifactoryPost) async { + private func wakeMailroomWatchers(about post: MailroomPost) async { for node in graph.nodes where node.id != post.authorID { - guard let watch = node.artifactoryWatch, watch.matches(post.topic) else { continue } + guard let watch = node.mailroomWatch, watch.matches(post.topic) else { continue } let preview = post.body.utf8.count > 140 ? String(post.body.prefix(140)) + "…" : post.body let nudge = - "artifactory — new post #\(post.id)\(topicSuffix(post)) from \(post.author): " - + "\(preview) — read it with: graphcode artifactory sync \(graph.project.path)" + "mailroom — new post #\(post.id)\(topicSuffix(post)) from \(post.author): " + + "\(preview) — read it with: graphcode mailroom sync \(graph.project.path)" await deliverAdHocMessage( to: node.id, text: nudge, from: nil, followUp: true, mirror: false) } } - private func topicSuffix(_ post: ArtifactoryPost) -> String { + private func topicSuffix(_ post: MailroomPost) -> String { post.topic.map { " (\($0))" } ?? "" } - /// Writes a shared communication onto the artifactory — the durable record the + /// Writes a shared communication onto the mailroom — the durable record the /// board keeps of everything the graph's loops said to each other. Record-only by /// design: the communication already reached its target (or is waiting in staged /// memory to), so mirroring must not ring the watchers, or a busy graph would have @@ -1667,63 +1667,63 @@ public actor GraphStore { /// Gated like every board write; body carries the target so a reader can tell a /// note to the room from a note to a peer. Written as `.record`, which is what keeps /// a talkative graph inside its own budget instead of evicting the notes. - private func recordArtifactoryCommunication( + private func recordMailroomCommunication( from senderID: UUID?, to target: LoopNode, text: String, topic: String ) { - guard onArtifactoryEnabled?() == true else { return } + guard onMailroomEnabled?() == true else { return } let sender = senderID.flatMap { graph.nodes[id: $0]?.title } ?? "a human" var body = "@\(target.title): \(text)" - if body.utf8.count > ArtifactoryPost.maxBodyBytes { + if body.utf8.count > MailroomPost.maxBodyBytes { // Room for the ellipsis itself, or the "1024-byte bound" would be 1026 in the // worst case. - while body.utf8.count > ArtifactoryPost.maxBodyBytes - 3 { body.removeLast() } + while body.utf8.count > MailroomPost.maxBodyBytes - 3 { body.removeLast() } body.append("…") } - let post = ArtifactoryPost( - id: Artifactory.nextID(after: graph.artifactory), at: Date(), authorID: senderID, + let post = MailroomPost( + id: Mailroom.nextID(after: graph.mailroom), at: Date(), authorID: senderID, author: sender, topic: topic, body: body, kind: .record) - graph.artifactory = Artifactory.pruned(graph.artifactory + [post]) + graph.mailroom = Mailroom.pruned(graph.mailroom + [post]) } /// Advances the reading loop's cursor to the newest post — the write half of - /// `graphcode artifactory sync`. Deliberately no memory record: sync is reading, + /// `graphcode mailroom sync`. Deliberately no memory record: sync is reading, /// not learning, and a log line per read would turn the log into a metronome. - private func artifactorySync(from readerID: UUID?) { - guard artifactoryIsOn() else { + private func mailroomSync(from readerID: UUID?) { + guard mailroomIsOn() else { announceError( - "the Artifactory is off — enable Artifactory in Settings " - + "(artifactoryEnabled in ~/.graphcode/settings.json)") + "the Mailroom is off — enable Mailroom in Settings " + + "(mailroomEnabled in ~/.graphcode/settings.json)") return } guard let readerID, graph.nodes[id: readerID] != nil else { announceError( - "artifactory sync needs a loop identity — run it from a loop's session " + "mailroom sync needs a loop identity — run it from a loop's session " + "($ZMX_SESSION); a human reading the board needs no cursor") return } - // Never moves backward: ids only grow (`Artifactory.nextID` is max-plus-one), so + // Never moves backward: ids only grow (`Mailroom.nextID` is max-plus-one), so // the max below only guards a board emptied by something other than pruning. - let latest = graph.artifactory.last?.id ?? 0 + let latest = graph.mailroom.last?.id ?? 0 // Read into a local first: reading and writing the cursor through the same // `IdentifiedArray` subscript in one expression is an overlapping access the // runtime treats as fatal exclusivity. - let current = graph.nodes[id: readerID]?.lastArtifactoryRead ?? 0 - graph.nodes[id: readerID]?.lastArtifactoryRead = max(latest, current) + let current = graph.nodes[id: readerID]?.lastMailroomRead ?? 0 + graph.nodes[id: readerID]?.lastMailroomRead = max(latest, current) } /// Subscribes or unsubscribes the calling loop. Recorded to the loop's memory so a /// relaunched session knows it is the project's watcher — the subscription lives on /// the node, but knowing *why* it is set is the session's to inherit. - private func artifactoryWatch(on: Bool, topic: String?, from watcherID: UUID?) { - guard artifactoryIsOn() else { + private func mailroomWatch(on: Bool, topic: String?, from watcherID: UUID?) { + guard mailroomIsOn() else { announceError( - "the Artifactory is off — enable Artifactory in Settings " - + "(artifactoryEnabled in ~/.graphcode/settings.json)") + "the Mailroom is off — enable Mailroom in Settings " + + "(mailroomEnabled in ~/.graphcode/settings.json)") return } guard let watcherID, graph.nodes[id: watcherID] != nil else { announceError( - "artifactory watch needs a loop identity — run it from a loop's session " + "mailroom watch needs a loop identity — run it from a loop's session " + "($ZMX_SESSION); the watcher is the loop the mail is delivered to") return } @@ -1732,20 +1732,20 @@ public actor GraphStore { topic.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } ?? Optional.none if let trimmed, trimmed.isEmpty { - announceError("artifactory watch refused: an empty topic is no topic — omit it") + announceError("mailroom watch refused: an empty topic is no topic — omit it") return } - graph.nodes[id: watcherID]?.artifactoryWatch = ArtifactoryWatch(topic: trimmed) + graph.nodes[id: watcherID]?.mailroomWatch = MailroomWatch(topic: trimmed) recordMemory( - watcherID, "artifactory: now watching \(trimmed.map { "'\($0)'" } ?? "all posts")") + watcherID, "mailroom: now watching \(trimmed.map { "'\($0)'" } ?? "all posts")") } else { // Idempotent, not an error: "stop watching" when nothing is watched is the // state the caller asked for, and an off state arriving twice is harmless in a // way a refusal isn't — the second call would be an agent retrying in a loop. - if graph.nodes[id: watcherID]?.artifactoryWatch != nil { - recordMemory(watcherID, "artifactory: stopped watching") + if graph.nodes[id: watcherID]?.mailroomWatch != nil { + recordMemory(watcherID, "mailroom: stopped watching") } - graph.nodes[id: watcherID]?.artifactoryWatch = nil + graph.nodes[id: watcherID]?.mailroomWatch = nil } } @@ -1782,7 +1782,7 @@ public actor GraphStore { // starts with no reading history; the watch subscription is a preference and // travels as one. for newID in plan.idMapping.values { - graph.nodes[id: newID]?.lastArtifactoryRead = nil + graph.nodes[id: newID]?.lastMailroomRead = nil } for (oldID, entries) in request.memoryByNodeID { guard let newID = plan.idMapping[oldID] else { continue } @@ -1847,17 +1847,17 @@ public actor GraphStore { // memory goes the same way — a log for a loop that no longer exists is litter. terminateSession(node) onRemoveMemory?(node.id) - // Its artifactory posts stay, with the handle to their author taken off them. + // Its mailroom posts stay, with the handle to their author taken off them. // Deleting the loop was never meant to retract what it *told other loops*: a note // on the board is addressed to whoever comes next, peers may already have acted on // it, and a board that un-says things is not a board. What the delete does take is // the id — nothing should be able to address a loop that no longer exists — and // the byline says plainly that the author is gone. - for post in graph.artifactory where post.authorID == node.id { - guard let index = graph.artifactory.firstIndex(where: { $0.id == post.id }) else { + for post in graph.mailroom where post.authorID == node.id { + guard let index = graph.mailroom.firstIndex(where: { $0.id == post.id }) else { continue } - graph.artifactory[index] = post.withAuthorDeleted() + graph.mailroom[index] = post.withAuthorDeleted() } // A composite's workers live in its sub-graph, on this node rather than in @@ -2094,7 +2094,7 @@ public actor GraphStore { if sessionMayStillBeLive, let ask = MessageBus.resolutionAsk( distillSkill: succeeded && node.loopType == .goalBased, - artifactoryProjectPath: artifactoryIsOn() ? graph.project.path : nil) + mailroomProjectPath: mailroomIsOn() ? graph.project.path : nil) { pendingResolutionNudges.append((nodeID, ask)) } @@ -2350,7 +2350,7 @@ public actor GraphStore { continue } // Delivered is what counts here, unlike the ad-hoc path: an edge message that - // failed transport was never sent, and the artifactory is a record of what + // failed transport was never sent, and the mailroom is a record of what // actually was. The transport text carries routing prefixes ("[graphcode] ", // the sender's name) that the record replaces with its own author/target // fields, so they are stripped before mirroring. @@ -2359,7 +2359,7 @@ public actor GraphStore { if record.hasPrefix("\(source.title): ") { record.removeFirst("\(source.title): ".count) } - recordArtifactoryCommunication(from: source.id, to: target, text: record, topic: "direct") + recordMailroomCommunication(from: source.id, to: target, text: record, topic: "direct") graph.edges[id: edgeID]?.fireCount += 1 } } @@ -2461,7 +2461,7 @@ public actor GraphStore { // out of the record the same way heartbeat ticks stay out of memory logs. var record = parts.joined(separator: " ") if let payload { record += " " + payload } - recordArtifactoryCommunication( + recordMailroomCommunication( from: source.id, to: target, text: record, topic: "handoff") } if let payload { @@ -2534,13 +2534,13 @@ public actor GraphStore { announceError("message to \(target.title) not delivered: empty message") return } - // The artifactory is the durable record of the graph's shared communication, so + // The mailroom is the durable record of the graph's shared communication, so // every direct message lands on it — whether the live session takes it now, a // busy one takes it at its next idle, or a dead one reads it at its next wake. // The internal watcher-wake passes `mirror: false`: the wake is *about* a post // that already exists, and recording it would have the board record itself. if mirror { - recordArtifactoryCommunication( + recordMailroomCommunication( from: senderID, to: target, text: trimmed, topic: "direct") } // Attributed when the sender is a loop in this graph, the way a message edge names @@ -2781,7 +2781,7 @@ public actor GraphStore { onRefinePlaybook: onRefinePlaybook, onRollbackPlaybook: onRollbackPlaybook, onAnnounceError: effects.errors.append, - onArtifactoryEnabled: onArtifactoryEnabled, + onMailroomEnabled: onMailroomEnabled, goalCache: goalCache, recurrence: effects.recurrence, subGraphDepth: subGraphDepth + 1) diff --git a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift index 208cb8ed..c8ed21c1 100644 --- a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift +++ b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift @@ -130,27 +130,27 @@ public indirect enum GraphCommand: Codable, Sendable, Equatable { /// mid-turn. Optional so frames from clients that predate the flag decode as the /// immediate send they always were. case messageNode(UUID, text: String, from: UUID?, followUp: Bool?) - /// Drop a note onto the project's Artifactory — the shared, unaddressed board (`graphcode - /// artifactory post`) any loop can write to for *whoever comes next*, without naming a + /// Drop a note onto the project's Mailroom — the shared, unaddressed board (`graphcode + /// mailroom post`) any loop can write to for *whoever comes next*, without naming a /// recipient or drawing an edge first. `topic` groups threads for watchers; `from` is /// attributed exactly as `messageNode`'s is (`ZMX_SESSION`), or `nil` from a human's - /// shell. Refused outright while the beta ramp has the Artifactory off - /// (`artifactoryEnabled` in `~/.graphcode/settings.json`) — a silent no-op would read, + /// shell. Refused outright while the beta ramp has the Mailroom off + /// (`mailroomEnabled` in `~/.graphcode/settings.json`) — a silent no-op would read, /// to the loop that sent it, as a post nobody answered. - case artifactoryPost(text: String, topic: String?, from: UUID?) - /// Mark every post on the Artifactory as read for the calling loop — `graphcode - /// artifactory sync`, the cursor half of reading. The CLI reads the board out of the + case mailroomPost(text: String, topic: String?, from: UUID?) + /// Mark every post on the Mailroom as read for the calling loop — `graphcode + /// mailroom sync`, 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. - case artifactorySync(from: UUID?) - /// Subscribe (`on: true`) or unsubscribe (`on: false`) the calling loop to Artifactory - /// posts — `graphcode artifactory watch`. A watched post is delivered the way a + case mailroomSync(from: UUID?) + /// Subscribe (`on: true`) or unsubscribe (`on: false`) the calling loop to Mailroom + /// posts — `graphcode mailroom watch`. A watched post is delivered the way a /// `--follow-up` message is: typed into a live idle session, staged to a busy one's /// memory, and for a loop that is gone, nowhere — the post itself is the durable /// half, waiting at the next wake. `topic` filters; `nil` hears everything. - case artifactoryWatch(on: Bool, topic: String?, from: UUID?) + case mailroomWatch(on: Bool, topic: String?, from: UUID?) /// Removes the node, every edge touching it, and its detached session. Irreversible /// — the app confirms before sending this. case deleteNode(UUID) diff --git a/GraphcodeKit/Sources/ProjectRegistry.swift b/GraphcodeKit/Sources/ProjectRegistry.swift index b83c1de5..408666bf 100644 --- a/GraphcodeKit/Sources/ProjectRegistry.swift +++ b/GraphcodeKit/Sources/ProjectRegistry.swift @@ -421,7 +421,7 @@ public actor ProjectRegistry { stored.prefix(while: { $0 != path }).contains(where: { Self.canonicalize($0) == canonical }) else { return true } let graph = persistence.loadGraph(path: path) - let isEmpty = (graph?.nodesAtAnyDepth.isEmpty ?? true) && (graph?.artifactory.isEmpty ?? true) + let isEmpty = (graph?.nodesAtAnyDepth.isEmpty ?? true) && (graph?.mailroom.isEmpty ?? true) if isEmpty { persistence.forgetProject(path: path) } return !isEmpty } @@ -618,7 +618,7 @@ public actor ProjectRegistry { }, // Read fresh per command, the way the heartbeat toggle is: the app resolving // the beta ramp (or a hand edit) applies to the next post with no restart. - onArtifactoryEnabled: { GraphcodeSettingsStore.load().artifactoryEnabled }) + onMailroomEnabled: { GraphcodeSettingsStore.load().mailroomEnabled }) stores[path] = newStore // Only on first load of this project — a time-based node's session outlives the app // but not a reboot, so something has to restart it, and this is the moment the diff --git a/GraphcodeKit/Sources/Sessions/MessageBus.swift b/GraphcodeKit/Sources/Sessions/MessageBus.swift index 281ec967..d38f61a0 100644 --- a/GraphcodeKit/Sources/Sessions/MessageBus.swift +++ b/GraphcodeKit/Sources/Sessions/MessageBus.swift @@ -89,9 +89,9 @@ public enum MessageBus { + "a short markdown recipe with a one-line description). If it was one-off work, " + "skip this." - /// The Artifactory's half of the same moment, and the board's only *pull*. + /// The Mailroom's half of the same moment, and the board's only *pull*. /// - /// Every other artifactory affordance is read-side — the briefing teaches the verbs, + /// Every other mailroom affordance is read-side — the briefing teaches the verbs, /// the digest and the status line remind a loop to look. Nothing asked anyone to /// write, and a board nobody writes to carries nothing to the loops that come after. /// Resolution is when a loop knows what it learned and has no further use for it. @@ -99,10 +99,10 @@ public enum MessageBus { /// Unlike the skill ask this fires on failure too, and for every loop type: a dead /// end is the single most valuable thing on a board, because it is the one finding /// a successor would otherwise pay for twice. - private static func artifactoryPostBody(projectPath: String) -> String { + private static func mailroomPostBody(projectPath: String) -> String { "Before you finish: if you learned something a peer or a successor should not have " + "to rediscover — a dead end, a decision, a claim you staked — leave it on the " - + "board with: graphcode artifactory post \(projectPath) [--topic ] . " + + "board with: graphcode mailroom post \(projectPath) [--topic ] . " + "One note, not a transcript. If there is nothing worth a peer's time, skip this." } @@ -110,11 +110,11 @@ public enum MessageBus { /// none. Assembled rather than queued separately so a goal loop that both succeeded /// and has a board to post to is interrupted once, not twice. public static func resolutionAsk( - distillSkill: Bool, artifactoryProjectPath: String? + distillSkill: Bool, mailroomProjectPath: String? ) -> String? { var parts: [String] = [] if distillSkill { parts.append(distillSkillBody) } - if let path = artifactoryProjectPath { parts.append(artifactoryPostBody(projectPath: path)) } + if let path = mailroomProjectPath { parts.append(mailroomPostBody(projectPath: path)) } guard !parts.isEmpty else { return nil } return prefix + parts.joined(separator: " ") } diff --git a/GraphcodeKit/Sources/Sessions/NodeMemory.swift b/GraphcodeKit/Sources/Sessions/NodeMemory.swift index dfcc40e8..30c67ce4 100644 --- a/GraphcodeKit/Sources/Sessions/NodeMemory.swift +++ b/GraphcodeKit/Sources/Sessions/NodeMemory.swift @@ -132,7 +132,7 @@ public enum NodeMemory { /// per session start, and the digest can never go stale against a log that grew /// underneath it. public static func writeWakeDigest( - projectPath: String, nodeID: UUID, artifactoryEnabled: Bool = false, + projectPath: String, nodeID: UUID, mailroomEnabled: Bool = false, baseURL: URL = SupportDirectory.url ) -> URL? { let all = entries(forProjectPath: projectPath, nodeID: nodeID, baseURL: baseURL) @@ -154,14 +154,14 @@ public enum NodeMemory { "with: graphcode node memo ", "", ] - if artifactoryEnabled { - // The reminder half of the Artifactory. The briefing teaches the board's verbs to + if mailroomEnabled { + // The reminder half of the Mailroom. The briefing teaches the board's verbs to // every launch; this line is what makes a *relaunching* loop — which should // check the board before redoing work a predecessor may have posted about — // remember to, without any per-node data racing into the shared briefing file. lines.append( - "The project's Artifactory is on: other loops may have left findings for you. " - + "Check at the start of a pass — graphcode artifactory sync — " + "The project's Mailroom is on: other loops may have left findings for you. " + + "Check at the start of a pass — graphcode mailroom sync — " + "and post anything a peer or successor should not have to rediscover.") lines.append("") } diff --git a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift index 6928466d..f88972de 100644 --- a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift +++ b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift @@ -11,7 +11,7 @@ import Foundation /// one file. python3 is already the launch path's scripting dependency (the Copilot /// trust seed), it's validated at add-connection time the same way `zmx` is, and the /// wire protocol it has to speak is four bytes of length plus JSON. The shim covers the -/// verbs the briefing teaches — create, send, memo, status, artifactory — and says so +/// verbs the briefing teaches — create, send, memo, status, mailroom — and says so /// for the rest, rather than half-implementing all of them. /// /// **Paths are `~/`-relative on purpose.** Nothing local knows the remote home @@ -174,11 +174,11 @@ public enum RemoteGraphAccess { graphcode node delete irreversible; stop is reversible graphcode node send graphcode node memo - graphcode artifactory post [--topic ] - graphcode artifactory sync [--headlines] [--full] [--mark] [--json] - graphcode artifactory read - graphcode artifactory list [--search ] [--json] - graphcode artifactory watch [--topic ] [--off] + graphcode mailroom post [--topic ] + graphcode mailroom sync [--headlines] [--full] [--mark] [--json] + graphcode mailroom read + graphcode mailroom list [--search ] [--json] + graphcode mailroom watch [--topic ] [--off] SAFETY Use `graphcode projects` to discover paths and `graphcode status` before retrying. @@ -187,7 +187,7 @@ public enum RemoteGraphAccess { `graphcode reap` recovery runs on the Mac, not this remote host: use it only there, and run `graphcode reap --dry-run` before the destructive form. - ARTIFACTORY + MAILROOM The shared board any loop can post to and any loop can read -- check it at the start of a pass. `sync` and `watch` are the calling loop's, so they need a session ($ZMX_SESSION); `list` and `read` are read-only and move no cursor. A @@ -355,7 +355,7 @@ public enum RemoteGraphAccess { return flags - # The board's own arithmetic, ported from ArtifactoryKit rather than asked for over the + # 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 @@ -373,25 +373,25 @@ public enum RemoteGraphAccess { HEADLINE_BUDGET = 80 - def artifactory_unread(posts, last_read): + 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 artifactory_needs_triage(posts): + 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 artifactory_cursor(graph, reader): + def mailroom_cursor(graph, reader): # (cursor, the graph knows this reader). The distinction is the status line's: a # foreign or stale id gets the plain count, never "0 unread for you". for node in graph.get("nodes") or []: if str(node.get("id") or "").upper() == reader: - return node.get("lastArtifactoryRead"), True + return node.get("lastMailroomRead"), True return None, False @@ -470,10 +470,10 @@ public enum RemoteGraphAccess { def render_board(graph, reader=None, headlines=False, search=None, auto_triage=False): project = graph.get("project") or {} - posts = graph.get("artifactory") or [] + posts = graph.get("mailroom") or [] if reader is not None: - cursor, _ = artifactory_cursor(graph, reader) - posts = artifactory_unread(posts, cursor) + cursor, _ = mailroom_cursor(graph, reader) + posts = mailroom_unread(posts, cursor) posts = filtered_posts(posts, search) if not posts: if search: @@ -482,15 +482,15 @@ public enum RemoteGraphAccess { return "no unread posts match '%s'" % search if reader is not None: return "no unread posts" - return ("the board is empty %s post one: graphcode artifactory post " + return ("the board is empty %s post one: graphcode mailroom post " " " % (EM_DASH, ELLIPSIS)) - triaged = auto_triage and artifactory_needs_triage(posts) - label = "artifactory" if reader is None else "artifactory, unread" + triaged = auto_triage and mailroom_needs_triage(posts) + label = "mailroom" if reader is None else "mailroom, unread" header = "%s %s: %d post%s" % (project.get("name", "?"), label, len(posts), "" if len(posts) == 1 else "s") if triaged: header += (" %s headlines only, that is a lot to read at once. Full text: " - "graphcode artifactory read %s " + "graphcode mailroom read %s " % (EM_DASH, project.get("path", ""))) lines = [header] for post in posts: @@ -507,7 +507,7 @@ public enum RemoteGraphAccess { def encoded_post(post): - # What JSONEncoder makes of an ArtifactoryPost: absent rather than null for the + # What JSONEncoder makes of an MailroomPost: absent rather than null for the # optionals, ISO-8601 for the date, and `kind` defaulted the way the hand-written # decoder defaults it for boards saved before records had their own quota. encoded = {"id": post.get("id"), "at": iso8601(post.get("at")), @@ -521,11 +521,11 @@ public enum RemoteGraphAccess { def render_board_json(graph, reader=None, search=None): - posts = graph.get("artifactory") or [] + posts = graph.get("mailroom") or [] last_read = None if reader is not None: - last_read, _ = artifactory_cursor(graph, reader) - posts = artifactory_unread(posts, last_read) + 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 @@ -537,20 +537,20 @@ public enum RemoteGraphAccess { return encoded.replace("/", "\\/") - def artifactory_status_line(graph, reader): - posts = graph.get("artifactory") or [] + def mailroom_status_line(graph, reader): + posts = graph.get("mailroom") or [] if not posts: return None plural = "" if len(posts) == 1 else "s" - cursor, known = artifactory_cursor(graph, reader) if reader else (None, False) + cursor, known = mailroom_cursor(graph, reader) if reader else (None, False) if not known: - return "artifactory: %d post%s" % (len(posts), plural) - return "artifactory: %d post%s, %d unread for you" % ( - len(posts), plural, len(artifactory_unread(posts, cursor))) + 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))) def render_posted(graph): - posts = graph.get("artifactory") or [] + posts = graph.get("mailroom") or [] if not posts: return "posted" post = posts[-1] @@ -571,10 +571,10 @@ public enum RemoteGraphAccess { lines.append(" %s %s %s %s" % ( node.get("id"), state, node.get("loopType"), node.get("title"))) # The board rides last: one line, only when there is anything on it, so a - # project that never touched the Artifactory renders as it always did. This is + # project that never touched the Mailroom renders as it always did. This is # the cheap "is there mail I should care about" check the briefing sends every # loop to `status` for, and the snapshot already holds everything it needs. - board = artifactory_status_line(graph, self_node_id()) + board = mailroom_status_line(graph, self_node_id()) if board: lines.append(" " + board) return "\n".join(lines) @@ -676,7 +676,7 @@ public enum RemoteGraphAccess { def run_and_report(project, inner, report): # `run_with_verdict` with the acknowledgement computed from the graph that comes - # back rather than fixed in advance -- what `artifactory post` needs to name the + # back rather than fixed in advance -- what `mailroom post` needs to name the # sequence number the note landed at. daemon = Daemon() project = resolve_project(daemon, project) @@ -767,7 +767,7 @@ public enum RemoteGraphAccess { return bool(arguments) and arguments[0] in HELP_FLAGS - ARTIFACTORY_FLAGS = { + MAILROOM_FLAGS = { "post": ("topic",), "sync": ("headlines", "mark", "json", "full"), "read": (), @@ -776,7 +776,7 @@ public enum RemoteGraphAccess { } - def artifactory_post_id(raw): + def mailroom_post_id(raw): # One-based by construction -- the daemon's ids start at 1 -- so "-7" is a typo, # never a post, and says so here rather than at the lookup. digits = raw[1:] if raw[:1] in ("+", "-") else raw @@ -787,12 +787,12 @@ public enum RemoteGraphAccess { fail("invalid value for post-id: %s" % raw) - def artifactory(arguments): - # Parsed in GraphcodeCommand.parseArtifactory's order -- subcommand, project path, + def mailroom(arguments): + # Parsed in GraphcodeCommand.parseMailroom's order -- subcommand, project path, # help anywhere, then the flags that subcommand allows -- so a mistyped flag is # refused here rather than silently ignored on the way to the daemon. if not arguments: - fail("missing artifactory subcommand") + fail("missing mailroom subcommand") subverb = arguments.pop(0) if wants_help(arguments): print(HELP) @@ -803,10 +803,10 @@ public enum RemoteGraphAccess { if any(argument in HELP_FLAGS for argument in arguments): print(HELP) return - if subverb not in ARTIFACTORY_FLAGS: - fail("unknown command: artifactory %s" % subverb) + if subverb not in MAILROOM_FLAGS: + fail("unknown command: mailroom %s" % subverb) for argument in arguments: - if argument.startswith("--") and argument[2:] not in ARTIFACTORY_FLAGS[subverb]: + if argument.startswith("--") and argument[2:] not in MAILROOM_FLAGS[subverb]: fail("unknown option: %s" % argument) flags = parse_flags(arguments) @@ -821,22 +821,22 @@ public enum RemoteGraphAccess { if not text: fail("missing note") payload = {"text": text, "topic": flags.get("topic"), "from": self_node_id()} - run_and_report(project, {"artifactoryPost": payload}, render_posted) + run_and_report(project, {"mailroomPost": payload}, render_posted) return if subverb == "sync": reader = self_node_id() if not reader: - fail("artifactory sync needs a loop identity %s run it from inside a loop's " + fail("mailroom sync needs a loop identity %s run it from inside a loop's " "session ($ZMX_SESSION); a human reading the board wants `graphcode " - "artifactory list`" % EM_DASH) + "mailroom 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) - daemon.send(graph_command(project, {"artifactorySync": {"from": reader}})) + daemon.send(graph_command(project, {"mailroomSync": {"from": reader}})) key, value = daemon.wait_for(["graphChanged", "errorOccurred"]) if key == "errorOccurred": fail(value["_0"]) @@ -845,7 +845,7 @@ public enum RemoteGraphAccess { if "json" in flags: print(render_board_json(graph, reader=reader)) elif "mark" in flags: - posts = graph.get("artifactory") or [] + posts = graph.get("mailroom") or [] latest = posts[-1].get("id", 0) if posts else 0 if latest > 0: print("marked read up to #%d" % latest) @@ -859,15 +859,15 @@ public enum RemoteGraphAccess { if subverb == "read": if not arguments or arguments[0].startswith("--"): fail("missing post-id") - post_id = artifactory_post_id(arguments[0]) + post_id = mailroom_post_id(arguments[0]) daemon = Daemon() project = resolve_project(daemon, project) graph = daemon.open_project(project) - for post in graph.get("artifactory") or []: + for post in graph.get("mailroom") or []: if post.get("id") == post_id: print(render_post(post)) return - fail("no post #%d on this board %s `graphcode artifactory list %s` shows the " + fail("no post #%d on this board %s `graphcode mailroom list %s` shows the " "ids that exist" % (post_id, EM_DASH, project)) if subverb == "list": @@ -882,7 +882,7 @@ public enum RemoteGraphAccess { watcher = self_node_id() if not watcher: - fail("artifactory watch needs a loop identity %s run it from inside a loop's " + fail("mailroom watch needs a loop identity %s run it from inside a loop's " "session ($ZMX_SESSION); the mail is delivered to the loop that watches" % EM_DASH) on = "off" not in flags @@ -895,7 +895,7 @@ public enum RemoteGraphAccess { else: acknowledgement = ("watching '%s' %s matching posts are typed in when the loop " "goes idle" % (topic, EM_DASH)) - run_with_verdict(project, {"artifactoryWatch": {"on": on, "topic": topic, + run_with_verdict(project, {"mailroomWatch": {"on": on, "topic": topic, "from": watcher}}, acknowledgement) def main(arguments): @@ -921,8 +921,8 @@ public enum RemoteGraphAccess { fail("missing project-path") run_and_print(arguments[0]) return - if verb == "artifactory": - artifactory(arguments) + if verb == "mailroom": + mailroom(arguments) return if verb != "node": fail("unknown or Mac-only command: %s (see `graphcode help`)" % verb) diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index be8ae216..02b48236 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -924,7 +924,7 @@ public enum ZmxSessionLauncher { projectPath != nil ? NodeMemory.writeWakeDigest( projectPath: projectPath ?? "", nodeID: node.id, - artifactoryEnabled: settings.artifactoryEnabled) + mailroomEnabled: settings.mailroomEnabled) : nil let wakePath: String? if let projectPath, remote != nil { diff --git a/GraphcodeKit/Sources/Templates/StarterTemplates.swift b/GraphcodeKit/Sources/Templates/StarterTemplates.swift index e682323e..c75569df 100644 --- a/GraphcodeKit/Sources/Templates/StarterTemplates.swift +++ b/GraphcodeKit/Sources/Templates/StarterTemplates.swift @@ -88,7 +88,7 @@ public enum StarterTemplates { /// one loop that understands the goal, splits it, and stays to put the pieces back /// together. A **Main** loop on purpose — `MAIN_LOOP.md` names this as the /// orchestration path — that cuts no worktree, since a coordinator reads and steers - /// while its children write. The Artifactory appears as the team's inbox, and the + /// while its children write. The Mailroom appears as the team's inbox, and the /// topic is the loop's to choose from the goal, so there is one thing to fill. public static var leadATeam: PromptTemplate { starter( @@ -101,7 +101,7 @@ public enum StarterTemplates { anything that needs watching rather than finishing a timed loop. Keep the \ integration for yourself. - The Artifactory is the team's inbox. Post your plan there under one topic named \ + The Mailroom is the team's inbox. Post your plan there under one topic named \ for this goal, have every child post its result there, watch that topic, and \ sync whenever you come back. Finish by checking the assembled result against \ the goal and posting a closing note. @@ -111,7 +111,7 @@ public enum StarterTemplates { // MARK: - Main // One line to fill, and both end when you close them, which is the whole type. Both // are shaped like the team lead above, at a smaller scale: do the first pass - // yourself, split only when the work actually splits, one Artifactory topic per + // yourself, split only when the work actually splits, one Mailroom topic per // job, and the main loop is the only voice the human hears. public static var whereDoesThisLive: PromptTemplate { @@ -123,7 +123,7 @@ public enum StarterTemplates { Map it before I touch it: where it's defined, everything that reads it, \ everything that writes it. Do the first pass yourself. If it reaches into more \ than a few areas, give each area its own goal loop to trace in depth and report \ - to the Artifactory under this symbol's name, and fold their reports into one \ + to the Mailroom under this symbol's name, and fold their reports into one \ map. Change nothing — the map is the deliverable. """) } @@ -136,7 +136,7 @@ public enum StarterTemplates { Reproduce it first. Then list the plausible causes, and if there is more than \ one, give each its own goal loop in its own worktree to confirm or rule it out, \ - reporting to the Artifactory under this symptom. Only you talk to me; the \ + reporting to the Mailroom under this symptom. Only you talk to me; the \ children report to you. Stop when you can tell me the cause with the evidence — \ I'll decide what to do about it. """) diff --git a/ArtifactoryKit/Sources/Artifactory.swift b/MailroomKit/Sources/Mailroom.swift similarity index 90% rename from ArtifactoryKit/Sources/Artifactory.swift rename to MailroomKit/Sources/Mailroom.swift index 1a8eccac..324a2c39 100644 --- a/ArtifactoryKit/Sources/Artifactory.swift +++ b/MailroomKit/Sources/Mailroom.swift @@ -1,9 +1,9 @@ import Foundation -/// One post on an Artifactory — the shared, unaddressed message board a graph of loops +/// One post on an Mailroom — the shared, unaddressed message board a graph of loops /// writes to and reads without wiring anything: `node send` and edges are addressed /// (a sender must already know a target's id, and the daemon routes to that one peer), -/// while the Artifactory is the ambient counterpart. A loop drops a note for *whoever +/// while the Mailroom is the ambient counterpart. A loop drops a note for *whoever /// comes next* — a decision made, a dead end hit, a claim staked — and any other loop, /// present or created after the author is gone, discovers it with one command. Posts /// survive their authors: they live on the graph itself, outlasting resolution, the @@ -12,7 +12,7 @@ import Foundation /// Small on purpose. A post is a note to a peer, not a transcript — the same bargain /// `NodeMemory`'s 512-byte log entries strike — and the caps below are what keep a /// wake digest's advice to "check the board" from costing a loop its context budget. -public struct ArtifactoryPost: Codable, Equatable, Identifiable, Sendable { +public struct MailroomPost: Codable, Equatable, Identifiable, Sendable { /// What kind of traffic a post is, which is what decides *whose* budget prunes it. /// /// The two share a board and nothing else. A note is somebody choosing to tell the @@ -22,7 +22,7 @@ public struct ArtifactoryPost: Codable, Equatable, Identifiable, Sendable { /// trying — evicted every note on it. Separate budgets are the fix: chatter can fill /// its own quota to the brim and never touch a note. public enum Kind: String, Codable, Sendable { - /// Somebody posted this on purpose (`graphcode artifactory post`). + /// Somebody posted this on purpose (`graphcode mailroom post`). case note /// The board's mirror of a delivered direct message or handoff. case record @@ -103,8 +103,8 @@ public struct ArtifactoryPost: Codable, Equatable, Identifiable, Sendable { /// peers may already have acted on, which is the one thing an append-only board must /// not do. What the delete does take is the handle: `authorID` goes, so nothing can /// address a loop that no longer exists, and the byline says plainly that it is gone. - public func withAuthorDeleted() -> ArtifactoryPost { - ArtifactoryPost( + public func withAuthorDeleted() -> MailroomPost { + MailroomPost( id: id, at: at, authorID: nil, author: "\(author) (deleted)", topic: topic, body: body, kind: kind) } @@ -115,10 +115,10 @@ public struct ArtifactoryPost: Codable, Equatable, Identifiable, Sendable { public static let maxTopicBytes = 64 } -/// A loop's standing subscription to its project's Artifactory — what turns the board +/// A loop's standing subscription to its project's Mailroom — what turns the board /// from something a loop must remember to poll into a mailbox that rings. `topic` /// `nil` hears every post; a topic hears only posts labelled the same way. -public struct ArtifactoryWatch: Codable, Equatable, Sendable { +public struct MailroomWatch: Codable, Equatable, Sendable { public var topic: String? public init(topic: String? = nil) { self.topic = topic } @@ -128,8 +128,8 @@ public struct ArtifactoryWatch: Codable, Equatable, Sendable { /// The board's own rules — the arithmetic every surface shares rather than /// re-derives, so the CLI's unread count and the daemon's cursor can never disagree. -public enum Artifactory { - /// How many *notes* a board keeps. The oldest fall off first: an Artifactory is a +public enum Mailroom { + /// How many *notes* a board keeps. The oldest fall off first: an Mailroom is a /// mailbox for the work that is happening, not an archive — a loop's durable /// findings belong in its memory log, and the board's job is carrying them to /// loops that cannot read that log. @@ -144,7 +144,7 @@ public enum Artifactory { /// The id the next post gets. Maximum-plus-one, never count-plus-one: pruning /// removes the oldest posts, and reusing their ids would make unread cursors /// mistake old mail for new. - public static func nextID(after posts: [ArtifactoryPost]) -> Int { + public static func nextID(after posts: [MailroomPost]) -> Int { (posts.map(\.id).max() ?? 0) + 1 } @@ -161,22 +161,22 @@ public enum Artifactory { public static let triageAfterBytes = 4096 /// Whether this many posts is more than a loop should be handed in full. - public static func needsTriage(_ posts: [ArtifactoryPost]) -> Bool { + public static func needsTriage(_ posts: [MailroomPost]) -> Bool { posts.count > triageAfterPosts || posts.reduce(0) { $0 + $1.body.utf8.count } > triageAfterBytes } /// The posts a loop with `lastRead` on its cursor has not seen yet. public static func unread( - in posts: [ArtifactoryPost], since lastRead: Int? - ) -> [ArtifactoryPost] { + in posts: [MailroomPost], since lastRead: Int? + ) -> [MailroomPost] { guard let lastRead else { return posts } return posts.filter { $0.id > lastRead } } /// A board pruned to both budgets, oldest of each kind gone first and the survivors /// back in one sequence. Applied by the store on every write so no caller can forget. - public static func pruned(_ posts: [ArtifactoryPost]) -> [ArtifactoryPost] { + public static func pruned(_ posts: [MailroomPost]) -> [MailroomPost] { let notes = posts.filter { $0.kind == .note } let records = posts.filter { $0.kind == .record } guard notes.count > maxNotes || records.count > maxRecords else { return posts } diff --git a/Package.swift b/Package.swift index e17c7972..58439a08 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,7 @@ let package = Package( name: "graphcode", platforms: [.macOS(.v15)], products: [ - .library(name: "ArtifactoryKit", targets: ["ArtifactoryKit"]), + .library(name: "MailroomKit", targets: ["MailroomKit"]), .library(name: "GraphcodeKit", targets: ["GraphcodeKit"]), .executable(name: "graphcode", targets: ["graphcode-cli"]), .executable(name: "graphcoded", targets: ["graphcoded"]), @@ -25,17 +25,17 @@ let package = Package( // Foundation-only, and listed here as well as in `Project.swift` for the reason // this file exists at all: Tuist builds the app, SwiftPM builds everything that // has to run on Linux, and a module added to one and not the other compiles on a - // Mac and fails CI. `GraphcodeKit` exposes `ArtifactoryPost` through `LoopGraph`, + // Mac and fails CI. `GraphcodeKit` exposes `MailroomPost` through `LoopGraph`, // so it is a product too — anything importing the kit needs this module in scope. .target( - name: "ArtifactoryKit", - path: "ArtifactoryKit/Sources", + name: "MailroomKit", + path: "MailroomKit/Sources", swiftSettings: [.swiftLanguageMode(.v5)] ), .target( name: "GraphcodeKit", dependencies: [ - "ArtifactoryKit", + "MailroomKit", .product(name: "IdentifiedCollections", package: "swift-identified-collections"), ], path: "GraphcodeKit/Sources", diff --git a/Project.swift b/Project.swift index d6afd6a7..0fed96e6 100644 --- a/Project.swift +++ b/Project.swift @@ -34,19 +34,19 @@ let project = Project( name: "graphcode", organizationName: "Graphcode", targets: [ - // `ArtifactoryKit` — the Artifactory domain model: one post type, one watch + // `MailroomKit` — the Mailroom domain model: one post type, one watch // subscription, and the caps/matching rules both the daemon and the CLI read. // Its own module, with no dependency beyond Foundation, so the shared board is // a thing GraphcodeKit links rather than a folder inside it — and so the // post shape can evolve without touching the session machinery. .target( - name: "ArtifactoryKit", + name: "MailroomKit", destinations: .macOS, product: .staticFramework, - bundleId: "\(bundleIdPrefix).artifactory", + bundleId: "\(bundleIdPrefix).mailroom", deploymentTargets: .macOS("15.0"), buildableFolders: [ - "ArtifactoryKit/Sources" + "MailroomKit/Sources" ] ), .target( @@ -59,7 +59,7 @@ let project = Project( "GraphcodeKit/Sources" ], dependencies: [ - .target(name: "ArtifactoryKit"), + .target(name: "MailroomKit"), .external(name: "IdentifiedCollections") ] ), diff --git a/docs/ramps.json b/docs/ramps.json index 7b18c1b1..2e748ec9 100644 --- a/docs/ramps.json +++ b/docs/ramps.json @@ -4,7 +4,7 @@ "beta": 100, "stable": 100 }, - "artifactory": { + "mailroom": { "beta": 100, "stable": 100 } diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index e1ed7381..d9c48c53 100644 --- a/graphcode-cli/Sources/main.swift +++ b/graphcode-cli/Sources/main.swift @@ -83,8 +83,8 @@ defer { client.closeConnection() } /// The calling loop's identity, when this CLI ran inside one — the `status` graph /// render uses it for the board's "unread for you" line, the same attribution every -/// artifactory verb derives from `ZMX_SESSION`. -let artifactoryReader = SurfaceRef.nodeID( +/// mailroom verb derives from `ZMX_SESSION`. +let mailroomReader = SurfaceRef.nodeID( fromZmxSessionName: ProcessInfo.processInfo.environment["ZMX_SESSION"] ?? "") /// Joins the project every verb addresses, and stops here when the daemon refuses it. @@ -123,7 +123,7 @@ func runAndPrintGraph(projectPath: String, _ commands: [DaemonCommand]) throws { guard !commands.isEmpty else { if let opened { - print(GraphcodeCommand.render(opened, artifactoryReader: artifactoryReader)) + print(GraphcodeCommand.render(opened, mailroomReader: mailroomReader)) } return } @@ -135,7 +135,7 @@ func runAndPrintGraph(projectPath: String, _ commands: [DaemonCommand]) throws { if case .graphChanged = $0 { return true } else { return false } } if case .graphChanged(let graph) = event { - print(GraphcodeCommand.render(graph, artifactoryReader: artifactoryReader)) + print(GraphcodeCommand.render(graph, mailroomReader: mailroomReader)) } } @@ -266,7 +266,7 @@ do { } if case .errorOccurred(let message) = updateVerdict { fail(message) } if case .graphChanged(let graph) = updateVerdict { - print(GraphcodeCommand.render(graph, artifactoryReader: artifactoryReader)) + print(GraphcodeCommand.render(graph, mailroomReader: mailroomReader)) } case .promoteNode(let projectPath, let nodeID, let promotion): @@ -289,7 +289,7 @@ do { } if case .errorOccurred(let message) = promoteVerdict { fail(message) } if case .graphChanged(let graph) = promoteVerdict { - print(GraphcodeCommand.render(graph, artifactoryReader: artifactoryReader)) + print(GraphcodeCommand.render(graph, mailroomReader: mailroomReader)) } case .memoNode(let projectPath, let nodeID, let text): @@ -350,7 +350,7 @@ do { projectPath: projectPath, [.graphCommand(projectPath: projectPath, command: .armComposite(nodeID))]) - case .artifactoryPost(let projectPath, let topic, let text): + case .mailroomPost(let projectPath, let topic, let text): // Attributed like `node send`: run from inside a loop, ZMX_SESSION names the // sender and readers see who posted; from a human's shell there is no variable // and the note reads as from "a human" — which is exactly the human's voice on @@ -361,7 +361,7 @@ do { try client.send( .graphCommand( projectPath: projectPath, - command: .artifactoryPost(text: text, topic: topic, from: author))) + command: .mailroomPost(text: text, topic: topic, from: author))) let postVerdict = try client.waitForEvent { event in switch event { case .graphChanged, .errorOccurred: return true @@ -373,21 +373,21 @@ do { print(GraphcodeCommand.renderPosted(graph)) } - case .artifactorySync(let projectPath, let headlines, let mark, let json, let full): - // Attributed like `node send` — and required, the one place an artifactory verb + case .mailroomSync(let projectPath, let headlines, let mark, let json, let full): + // Attributed like `node send` — and required, the one place an mailroom verb // refuses a human shell up front: the cursor is the calling loop's, so with no // ZMX_SESSION there is nobody to advance it for, and the daemon's refusal would - // arrive only after the round trip. Reading without a cursor is `artifactory list`. + // arrive only after the round trip. Reading without a cursor is `mailroom list`. let reader = SurfaceRef.nodeID( fromZmxSessionName: ProcessInfo.processInfo.environment["ZMX_SESSION"] ?? "") guard let reader else { fail( - "artifactory sync needs a loop identity — run it from inside a loop's session " - + "($ZMX_SESSION); a human reading the board wants `graphcode artifactory list`") + "mailroom sync needs a loop identity — run it from inside a loop's session " + + "($ZMX_SESSION); a human reading the board wants `graphcode mailroom list`") } let opened = try openProject(projectPath) try client.send( - .graphCommand(projectPath: projectPath, command: .artifactorySync(from: reader))) + .graphCommand(projectPath: projectPath, command: .mailroomSync(from: reader))) let syncVerdict = try client.waitForEvent { event in switch event { case .graphChanged, .errorOccurred: return true @@ -404,12 +404,12 @@ do { // than to latest, which nothing so far has needed. if let graph = opened { if json { - print(GraphcodeCommand.renderArtifactoryJSON(graph, unreadFor: reader)) + 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.artifactory.last?.id, latest > 0 { + if let latest = graph.mailroom.last?.id, latest > 0 { print("marked read up to #\(latest)") } else { print("marked read — the board is empty") @@ -419,53 +419,53 @@ do { // 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.renderArtifactory( + GraphcodeCommand.renderMailroom( graph, unreadFor: reader, headlines: headlines, autoTriage: !headlines && !full)) } } - case .artifactoryRead(let projectPath, let postID): + 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.artifactory.first(where: { $0.id == postID }) else { + guard let post = graph.mailroom.first(where: { $0.id == postID }) else { fail( - "no post #\(postID) on this board — `graphcode artifactory list \(projectPath)` " + "no post #\(postID) on this board — `graphcode mailroom list \(projectPath)` " + "shows the ids that exist") } print(GraphcodeCommand.render(post)) } - case .artifactoryList(let projectPath, let search, let json): + 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.renderArtifactoryJSON(graph, search: search)) + print(GraphcodeCommand.renderMailroomJSON(graph, search: search)) } else { - print(GraphcodeCommand.renderArtifactory(graph, search: search)) + print(GraphcodeCommand.renderMailroom(graph, search: search)) } } - case .artifactoryWatch(let projectPath, let on, let topic): + case .mailroomWatch(let projectPath, let on, let topic): // Attributed like `node send` — and required like `sync`: the subscription is // the calling loop's, because the mail is delivered to a session, not a shell. let watcher = SurfaceRef.nodeID( fromZmxSessionName: ProcessInfo.processInfo.environment["ZMX_SESSION"] ?? "") guard let watcher else { fail( - "artifactory watch needs a loop identity — run it from inside a loop's session " + "mailroom watch needs a loop identity — run it from inside a loop's session " + "($ZMX_SESSION); the mail is delivered to the loop that watches") } try openProject(projectPath) try client.send( .graphCommand( projectPath: projectPath, - command: .artifactoryWatch(on: on, topic: topic, from: watcher))) + command: .mailroomWatch(on: on, topic: topic, from: watcher))) let watchVerdict = try client.waitForEvent { event in switch event { case .graphChanged, .errorOccurred: return true @@ -581,7 +581,7 @@ do { + "and \(bundle.graphSnapshot.edges.count) edge(s) with fresh identities" + (resumingSessions == 0 ? "" : "; \(resumingSessions) will resume their exported conversations")) - print(GraphcodeCommand.render(graph, artifactoryReader: artifactoryReader)) + print(GraphcodeCommand.render(graph, mailroomReader: mailroomReader)) } } } catch DaemonSocketClient.ClientError.timedOut { diff --git a/graphcode/Sources/Clients/FeatureRamps.swift b/graphcode/Sources/Clients/FeatureRamps.swift index 69a46f13..bffae137 100644 --- a/graphcode/Sources/Clients/FeatureRamps.swift +++ b/graphcode/Sources/Clients/FeatureRamps.swift @@ -23,7 +23,7 @@ enum FeatureRamps { enum Feature: String { case codespaces - case artifactory + case mailroom /// What answers when no ramps.json has ever been fetched (and when the fetch /// fails). Kept in step with the *shipped* ramp state: a feature ramped fully on @@ -32,7 +32,7 @@ enum FeatureRamps { var defaultPercents: [String: Int] { switch self { case .codespaces: return ["beta": 100, "stable": 100] - case .artifactory: return ["beta": 100, "stable": 100] + case .mailroom: return ["beta": 100, "stable": 100] } } } diff --git a/graphcode/Sources/Features/App/AppFeature+LoopSessions.swift b/graphcode/Sources/Features/App/AppFeature+LoopSessions.swift index 059d70fb..dca35524 100644 --- a/graphcode/Sources/Features/App/AppFeature+LoopSessions.swift +++ b/graphcode/Sources/Features/App/AppFeature+LoopSessions.swift @@ -167,8 +167,8 @@ extension AppFeature { layout: layout, projectPath: projectPath, projectName: graph.project.name) - state.openLoop?.seenArtifactoryPostID = - LoopWorkspaceRail.loadSeenArtifactoryPost(forProjectPath: projectPath) + state.openLoop?.seenMailroomPostID = + LoopWorkspaceRail.loadSeenMailroomPost(forProjectPath: projectPath) state.selectedProjectPath = projectPath } } diff --git a/graphcode/Sources/Features/App/AppFeature.swift b/graphcode/Sources/Features/App/AppFeature.swift index 46c1f178..9a3437a7 100644 --- a/graphcode/Sources/Features/App/AppFeature.swift +++ b/graphcode/Sources/Features/App/AppFeature.swift @@ -588,13 +588,13 @@ struct AppFeature { // A human's note reaching the daemon. `from: nil` is the whole point: a click in // the app carries no `ZMX_SESSION`, so the board attributes it to "a human" — // the same attribution the CLI gives a person's shell. - case .openLoop(.artifactoryPostSubmitted(let text, let topic)): + case .openLoop(.mailroomPostSubmitted(let text, let topic)): guard let projectPath = state.openLoop?.projectPath else { return .none } return .run { _ in try? await orchestratorClient.send( .graphCommand( projectPath: projectPath, - command: .artifactoryPost(text: text, topic: topic, from: nil))) + command: .mailroomPost(text: text, topic: topic, from: nil))) } case .openLoop(.railTargetTapped(let nodeID)): diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift index 6ae3fd9d..14db342a 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift @@ -39,7 +39,7 @@ struct LoopWorkspaceFeature { /// summary's fold: they answer different questions, and someone who wants the sentence /// and not the diagram — or the diagram and not the sentence — is not being perverse. var isBoardFolded = LoopWorkspaceRail.loadBoardFolded() - var isArtifactoryFolded = LoopWorkspaceRail.loadArtifactoryFolded() + var isMailroomFolded = LoopWorkspaceRail.loadMailroomFolded() /// Whether a rail width has ever been committed by a drag on this machine. /// /// What lets a board open the rail wider without ever overruling a width somebody @@ -61,10 +61,10 @@ struct LoopWorkspaceFeature { /// The newest board post that was on screen when a workspace in this project was /// last left — the board's counterpart to `seenBeatID`, and a fact about the /// person at the screen for the same reason. It is *not* the loop's own - /// `lastArtifactoryRead`: that cursor moves when the loop runs `artifactory sync`, + /// `lastMailroomRead`: that cursor moves when the loop runs `mailroom sync`, /// which nobody can do from the app, and the rail is what a human reads. Loaded /// from defaults by whoever builds this state (`AppFeature`), per project. - var seenArtifactoryPostID: Int? + var seenMailroomPostID: Int? var layout: TerminalLayout // The project folder every surface without its own worktree binding should open // in — a loop's shells shouldn't land in the app's own launch directory (usually @@ -116,11 +116,11 @@ struct LoopWorkspaceFeature { case summaryFoldToggled /// The board section's header row. case boardFoldToggled - case artifactoryFoldToggled + case mailroomFoldToggled /// A human leaving a note on the board from the rail. Handled by `AppFeature`, /// which is the level holding the daemon connection — the same division as /// `primarySurfaceExited`. - case artifactoryPostSubmitted(text: String, topic: String?) + case mailroomPostSubmitted(text: String, topic: String?) /// The board section's expand button, and the cover's own close. case boardExpandToggled /// The amber block's `Answer it` — the question is in the terminal, so this is a @@ -293,14 +293,14 @@ struct LoopWorkspaceFeature { LoopWorkspaceRail.saveBoardFolded(state.isBoardFolded) return .none - case .artifactoryFoldToggled: - state.isArtifactoryFolded.toggle() - LoopWorkspaceRail.saveArtifactoryFolded(state.isArtifactoryFolded) + case .mailroomFoldToggled: + state.isMailroomFolded.toggle() + 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`. - case .artifactoryPostSubmitted: + case .mailroomPostSubmitted: return .none case .boardExpandToggled: @@ -325,11 +325,11 @@ struct LoopWorkspaceFeature { // Only what was actually on screen counts as looked at: a hidden rail or a // folded section showed no posts, and marking them seen would clear a badge // the human never had a chance to read. - if state.isRailVisible, !state.isArtifactoryFolded, - let newest = ArtifactoryPresentation.notes(in: state.graph).last?.id + if state.isRailVisible, !state.isMailroomFolded, + let newest = MailroomPresentation.notes(in: state.graph).last?.id { - state.seenArtifactoryPostID = newest - LoopWorkspaceRail.saveSeenArtifactoryPost(newest, forProjectPath: state.projectPath) + state.seenMailroomPostID = newest + LoopWorkspaceRail.saveSeenMailroomPost(newest, forProjectPath: state.projectPath) } return .none diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift index 8b55b459..5edb3a9a 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift @@ -25,23 +25,23 @@ struct LoopWorkspaceRail: View { /// Whether the board section is collapsed to its header. Per window and persisted, /// beside the summary's own fold. let isBoardFolded: Bool - /// Whether this project's Artifactory is switched on, passed in as a plain value the + /// Whether this project's Mailroom is switched on, passed in as a plain value the /// way `AppSidebarView` takes `offersCodespaces`: the settings model is `@Observable` /// and the render path should not be reading a file. - let artifactoryEnabled: Bool + let mailroomEnabled: Bool /// Whether the board section is collapsed to its one line, beside the summary's and /// the diagram's own folds. - let isArtifactoryFolded: Bool - /// See `LoopWorkspaceFeature.seenArtifactoryPostID`. - let seenArtifactoryPostID: Int? + let isMailroomFolded: Bool + /// See `LoopWorkspaceFeature.seenMailroomPostID`. + let seenMailroomPostID: Int? let onSummaryFoldToggled: () -> Void let onSummaryAnswerTapped: () -> Void let onBoardFoldToggled: () -> Void let onBoardExpanded: () -> Void - let onArtifactoryFoldToggled: () -> Void + let onMailroomFoldToggled: () -> Void /// Body and optional topic. Posts as "a human": a click in the app carries no loop /// identity, which is exactly what a person addressing the whole graph is. - let onArtifactoryPost: (String, String?) -> Void + let onMailroomPost: (String, String?) -> Void let onTargetTapped: (UUID) -> Void /// The handoff's number, and now the floor rather than the fixed size. Below this the @@ -75,33 +75,33 @@ struct LoopWorkspaceRail: View { } /// The newest board post that was on screen when a human last left a workspace in - /// this project — what the ARTIFACTORY section's `SINCE YOU LOOKED` rule and its + /// this project — what the MAILROOM section's `SINCE YOU LOOKED` rule and its /// `N NEW` badge are drawn against. Keyed by project because the board is the /// project's: opening a different loop in the same project is not "not having /// looked". Persisted, unlike the summary's `seenBeatID`, because a board pointer /// that reset on relaunch would mark every post new again each morning. - static func seenArtifactoryPostDefaultsKey(forProjectPath path: String) -> String { - "artifactorySeenPostID." + path + static func seenMailroomPostDefaultsKey(forProjectPath path: String) -> String { + "mailroomSeenPostID." + path } - static func loadSeenArtifactoryPost(forProjectPath path: String) -> Int? { + static func loadSeenMailroomPost(forProjectPath path: String) -> Int? { let stored = UserDefaults.standard.integer( - forKey: seenArtifactoryPostDefaultsKey(forProjectPath: path)) + forKey: seenMailroomPostDefaultsKey(forProjectPath: path)) return stored > 0 ? stored : nil } - static func saveSeenArtifactoryPost(_ id: Int, forProjectPath path: String) { - UserDefaults.standard.set(id, forKey: seenArtifactoryPostDefaultsKey(forProjectPath: path)) + static func saveSeenMailroomPost(_ id: Int, forProjectPath path: String) { + UserDefaults.standard.set(id, forKey: seenMailroomPostDefaultsKey(forProjectPath: path)) } - static let artifactoryFoldedDefaultsKey = "loopArtifactorySectionFolded" + static let mailroomFoldedDefaultsKey = "loopMailroomSectionFolded" - static func loadArtifactoryFolded() -> Bool { - UserDefaults.standard.bool(forKey: artifactoryFoldedDefaultsKey) + static func loadMailroomFolded() -> Bool { + UserDefaults.standard.bool(forKey: mailroomFoldedDefaultsKey) } - static func saveArtifactoryFolded(_ folded: Bool) { - UserDefaults.standard.set(folded, forKey: artifactoryFoldedDefaultsKey) + static func saveMailroomFolded(_ folded: Bool) { + UserDefaults.standard.set(folded, forKey: mailroomFoldedDefaultsKey) } static let boardFoldedDefaultsKey = "loopBoardSectionFolded" @@ -163,12 +163,12 @@ struct LoopWorkspaceRail: View { node: LoopNode, graph: LoopGraph, summarising: Bool = LoopSummaryPresentation.isProducing, drawing: Bool = SummaryBoardPresentation.isDrawing, - artifactoryEnabled: Bool = SettingsModel.shared.settings.artifactoryEnabled + mailroomEnabled: Bool = SettingsModel.shared.settings.mailroomEnabled ) -> Bool { // A board with anything on it is reason enough to open the rail: it is the one // section whose content came from *other* loops, so the loop you are looking at // being wired to nothing says nothing about whether there is mail. - ArtifactoryPresentation.hasContent(graph: graph, enabled: artifactoryEnabled) + MailroomPresentation.hasContent(graph: graph, enabled: mailroomEnabled) || graph.edges.contains { $0.from == node.id || $0.to == node.id } || node.metricHistory.count >= 2 // A loop that is narrating has something to say whether or not it is wired to @@ -205,8 +205,8 @@ struct LoopWorkspaceRail: View { /// short window a fixed 600pt cap was enough, with THIS LOOP's 118 and the summary's /// 120 floor, to overflow the stack and push the foot of the rail off the bottom. /// A share cannot overflow on its own, and 40% still shows a conversation. - static func artifactoryHeightCap(railHeight: CGFloat) -> CGFloat { - min(ArtifactorySection.maxScrollHeight, max(160, railHeight * 0.4)) + static func mailroomHeightCap(railHeight: CGFloat) -> CGFloat { + min(MailroomSection.maxScrollHeight, max(160, railHeight * 0.4)) } var body: some View { @@ -214,7 +214,7 @@ struct LoopWorkspaceRail: View { // guess from within a scroll view — the rail's height is what the board's share // is a share *of*. GeometryReader { proxy in - stack(artifactoryCap: Self.artifactoryHeightCap(railHeight: proxy.size.height)) + stack(mailroomCap: Self.mailroomHeightCap(railHeight: proxy.size.height)) } .frame(width: width) .frame(maxHeight: .infinity) @@ -224,7 +224,7 @@ struct LoopWorkspaceRail: View { } } - private func stack(artifactoryCap: CGFloat) -> some View { + private func stack(mailroomCap: CGFloat) -> some View { VStack(alignment: .leading, spacing: 10) { // Above `THIS LOOP` rather than below it: what the loop is doing this second // outranks where it sits in the graph, and a section you have to scroll to is a @@ -266,11 +266,11 @@ struct LoopWorkspaceRail: View { // the button that answers it. Low and against the footer is also where a message // board belongs: newest at the bottom, composer under it, the way every other // thing you read messages in is arranged. - if ArtifactoryPresentation.hasContent(graph: graph, enabled: artifactoryEnabled) { - ArtifactorySection( - graph: graph, seenPostID: seenArtifactoryPostID, isFolded: isArtifactoryFolded, - maxHeight: artifactoryCap, - onToggleFold: onArtifactoryFoldToggled, onPost: onArtifactoryPost) + if MailroomPresentation.hasContent(graph: graph, enabled: mailroomEnabled) { + MailroomSection( + graph: graph, seenPostID: seenMailroomPostID, isFolded: isMailroomFolded, + maxHeight: mailroomCap, + onToggleFold: onMailroomFoldToggled, onPost: onMailroomPost) } footer } diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift index 9e8cdf0e..33022d27 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift @@ -15,7 +15,7 @@ struct LoopWorkspaceView: View { /// Followed live rather than captured once, like `AppView`'s activity strip: a board /// switched on in Settings should appear without a relaunch, and `SettingsModel` is /// `@Observable`, so reading it here re-renders on the same pass the toggle does. - private var artifactoryEnabled: Bool { SettingsModel.shared.settings.artifactoryEnabled } + private var mailroomEnabled: Bool { SettingsModel.shared.settings.mailroomEnabled } var body: some View { HStack(spacing: 0) { workspace @@ -27,16 +27,16 @@ struct LoopWorkspaceView: View { isSummaryFolded: store.isSummaryFolded, seenBeatID: store.seenBeatID, isBoardFolded: store.isBoardFolded, - artifactoryEnabled: artifactoryEnabled, - isArtifactoryFolded: store.isArtifactoryFolded, - seenArtifactoryPostID: store.seenArtifactoryPostID, + mailroomEnabled: mailroomEnabled, + isMailroomFolded: store.isMailroomFolded, + seenMailroomPostID: store.seenMailroomPostID, onSummaryFoldToggled: { store.send(.summaryFoldToggled) }, onSummaryAnswerTapped: { store.send(.summaryAnswerTapped) }, onBoardFoldToggled: { store.send(.boardFoldToggled) }, onBoardExpanded: { store.send(.boardExpandToggled) }, - onArtifactoryFoldToggled: { store.send(.artifactoryFoldToggled) }, - onArtifactoryPost: { text, topic in - store.send(.artifactoryPostSubmitted(text: text, topic: topic)) + onMailroomFoldToggled: { store.send(.mailroomFoldToggled) }, + onMailroomPost: { text, topic in + store.send(.mailroomPostSubmitted(text: text, topic: topic)) } ) { targetID in store.send(.railTargetTapped(targetID)) diff --git a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift b/graphcode/Sources/Features/LoopWorkspace/MailroomSection.swift similarity index 91% rename from graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift rename to graphcode/Sources/Features/LoopWorkspace/MailroomSection.swift index 9d1032fa..5200993e 100644 --- a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift +++ b/graphcode/Sources/Features/LoopWorkspace/MailroomSection.swift @@ -1,9 +1,9 @@ -import ArtifactoryKit +import MailroomKit import GraphcodeKit import SwiftUI /// What the rail needs to know about the board without building a view to find out. -enum ArtifactoryPresentation { +enum MailroomPresentation { /// Whether this graph's board has anything to show. Absent while empty, for the /// reason the whole rail is absent while empty: a panel that is permanently blank /// teaches people to stop looking at the one beside it. @@ -11,39 +11,39 @@ enum ArtifactoryPresentation { /// The gate is the same bit the daemon enforces, read by the caller rather than here /// so the render path never touches the settings file. static func hasContent(graph: LoopGraph, enabled: Bool) -> Bool { - enabled && !graph.artifactory.isEmpty + enabled && !graph.mailroom.isEmpty } /// The posts somebody wrote on purpose, newest last — the direction the summary and /// the terminal beside it already run. - static func notes(in graph: LoopGraph) -> [ArtifactoryPost] { - graph.artifactory.filter { $0.kind == .note } + static func notes(in graph: LoopGraph) -> [MailroomPost] { + graph.mailroom.filter { $0.kind == .note } } /// The mirrored direct messages and handoffs. Kept apart from the notes because they /// are receipts for deliveries that already happened, not something written to be /// read here. - static func records(in graph: LoopGraph) -> [ArtifactoryPost] { - graph.artifactory.filter { $0.kind == .record } + static func records(in graph: LoopGraph) -> [MailroomPost] { + graph.mailroom.filter { $0.kind == .record } } /// How many notes have landed since the human last looked — `seenPostID` is - /// `LoopWorkspaceFeature.seenArtifactoryPostID`, not the loop's sync cursor. Records + /// `LoopWorkspaceFeature.seenMailroomPostID`, not the loop's sync cursor. Records /// are excluded: they are folded away by default, and a badge counting mail nobody is /// being shown is a badge that cannot be cleared. static func unreadNoteCount(graph: LoopGraph, seenPostID: Int?) -> Int { - Artifactory.unread(in: notes(in: graph), since: seenPostID).count + Mailroom.unread(in: notes(in: graph), since: seenPostID).count } } -/// The Artifactory in the workspace rail — a peer of `LoopSummarySection` and +/// The Mailroom in the workspace rail — a peer of `LoopSummarySection` and /// `SummaryBoardSection`, and the one place a human meets the board without a shell. /// /// The board is how loops leave notes for whoever comes next, and until this section /// existed the only way to read one was a CLI verb nobody had been told about. That is /// the whole reason it is here rather than behind a menu: a coordination channel a /// supervisor never sees is the failure mode, not a missing convenience. -struct ArtifactorySection: View { +struct MailroomSection: View { let graph: LoopGraph /// What `SINCE YOU LOOKED` means here: the newest post that was on screen when this /// person last left a workspace in the project. The same words two sections up mean @@ -51,9 +51,9 @@ struct ArtifactorySection: View { let seenPostID: Int? let isFolded: Bool /// How tall the scroll box may grow before it scrolls — the rail's share for the - /// board (`LoopWorkspaceRail.artifactoryHeightCap`), never more than + /// board (`LoopWorkspaceRail.mailroomHeightCap`), never more than /// `maxScrollHeight`. - var maxHeight: CGFloat = ArtifactorySection.maxScrollHeight + var maxHeight: CGFloat = MailroomSection.maxScrollHeight let onToggleFold: () -> Void /// Posts as "a human" — a click in the app has no `ZMX_SESSION` and no loop identity, /// which is exactly what a person talking to the whole graph is. @@ -65,7 +65,7 @@ struct ArtifactorySection: View { /// The most the board's scroll box will ever be, on any window: about ten posts at /// the rail's default width — enough to read a conversation, not so many that the /// rail is nothing but the board. The rail hands down a smaller cap on a short - /// window (`LoopWorkspaceRail.artifactoryHeightCap`); this is the ceiling on that. + /// window (`LoopWorkspaceRail.mailroomHeightCap`); this is the ceiling on that. static let maxScrollHeight: CGFloat = 600 @State private var showsRecords = false @@ -74,10 +74,10 @@ struct ArtifactorySection: View { @State private var draftTopic = "" @FocusState private var draftFocused: Bool - private var notes: [ArtifactoryPost] { ArtifactoryPresentation.notes(in: graph) } - private var records: [ArtifactoryPost] { ArtifactoryPresentation.records(in: graph) } + private var notes: [MailroomPost] { MailroomPresentation.notes(in: graph) } + private var records: [MailroomPost] { MailroomPresentation.records(in: graph) } private var unread: Int { - ArtifactoryPresentation.unreadNoteCount(graph: graph, seenPostID: seenPostID) + MailroomPresentation.unreadNoteCount(graph: graph, seenPostID: seenPostID) } /// The id the unread rule is drawn above — the first note that landed after the human @@ -146,7 +146,7 @@ struct ArtifactorySection: View { private var header: some View { HStack(spacing: 7) { - Text("ARTIFACTORY") + Text("MAILROOM") .font(.system(size: 10.5, weight: .bold)) .tracking(0.63) .foregroundStyle(.white.opacity(0.5)) @@ -255,7 +255,7 @@ struct ArtifactorySection: View { VStack(alignment: .leading, spacing: 7) { ForEach(records.suffix(8)) { record in HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(ArtifactoryPost.stampFormat.string(from: record.at)) + Text(MailroomPost.stampFormat.string(from: record.at)) .font(.system(size: 10.5, design: .monospaced)) .foregroundStyle(.white.opacity(0.32)) Text(record.body) @@ -279,7 +279,7 @@ struct ArtifactorySection: View { // MARK: - Posts - private func postRow(_ post: ArtifactoryPost) -> some View { + private func postRow(_ post: MailroomPost) -> some View { let read = !unreadIDs.contains(post.id) return HStack(alignment: .top, spacing: 8) { RoundedRectangle(cornerRadius: 1) @@ -294,7 +294,7 @@ struct ArtifactorySection: View { .foregroundStyle(accent(for: post).opacity(read ? 0.85 : 1)) .lineLimit(1) } - Text(ArtifactoryPost.stampFormat.string(from: post.at)) + Text(MailroomPost.stampFormat.string(from: post.at)) .font(.system(size: 10.5, design: .monospaced)) .foregroundStyle(.white.opacity(0.4)) Spacer(minLength: 0) @@ -322,7 +322,7 @@ struct ArtifactorySection: View { /// A post wears its author's loop colour, so a board read at a glance says who is /// talking before it says what about. A human's note is the achromatic slot, which is /// the one distinction no dichromacy erodes — see `LoopTypeAppearance.accent`. - private func accent(for post: ArtifactoryPost?) -> Color { + private func accent(for post: MailroomPost?) -> Color { guard let post else { return .white.opacity(0.3) } guard let authorID = post.authorID, let author = graph.nodes[id: authorID] else { return LoopType.sketch.accent @@ -398,7 +398,7 @@ struct ArtifactorySection: View { } private var remainingBytes: Int { - ArtifactoryPost.maxBodyBytes - trimmedDraft.utf8.count + MailroomPost.maxBodyBytes - trimmedDraft.utf8.count } private var canPost: Bool { !trimmedDraft.isEmpty && remainingBytes >= 0 } diff --git a/graphcode/Sources/Features/Settings/SettingsModel.swift b/graphcode/Sources/Features/Settings/SettingsModel.swift index f31d1b4d..71ef9e27 100644 --- a/graphcode/Sources/Features/Settings/SettingsModel.swift +++ b/graphcode/Sources/Features/Settings/SettingsModel.swift @@ -16,11 +16,11 @@ import Observation final class SettingsModel { static let shared = SettingsModel() - /// The user's explicit Artifactory flip, kept apart from `settings` on purpose: the + /// The user's explicit Mailroom flip, kept apart from `settings` on purpose: the /// ramp decides what an install that has never chosen boots on, but once a human /// has flipped the switch the ramp never overrides them — the way `updateChannel` /// does for updates. - static let artifactoryChoiceDefaultsKey = "artifactoryChoice" + static let mailroomChoiceDefaultsKey = "mailroomChoice" var settings: GraphcodeSettings { didSet { @@ -39,33 +39,33 @@ final class SettingsModel { } } - /// The Artifactory as a switch, following `betaUpdates`' shape — but the daemon - /// enforces this one, so a flip writes `artifactoryEnabled` into `settings` (which + /// The Mailroom as a switch, following `betaUpdates`' shape — but the daemon + /// enforces this one, so a flip writes `mailroomEnabled` into `settings` (which /// saves the file the daemon reads) *and* records the explicit choice that then /// outranks the ramp for good. - var artifactoryEnabled: Bool { + var mailroomEnabled: Bool { didSet { - UserDefaults.standard.set(artifactoryEnabled, forKey: Self.artifactoryChoiceDefaultsKey) - settings.artifactoryEnabled = artifactoryEnabled + UserDefaults.standard.set(mailroomEnabled, forKey: Self.mailroomChoiceDefaultsKey) + settings.mailroomEnabled = mailroomEnabled } } private init() { let loaded = GraphcodeSettingsStore.load() - let artifactory = Self.resolvesArtifactory( - loaded: loaded.artifactoryEnabled, + let mailroom = Self.resolvesMailroom( + loaded: loaded.mailroomEnabled, explicitChoice: - UserDefaults.standard.object(forKey: Self.artifactoryChoiceDefaultsKey) as? Bool, - rampedOn: FeatureRamps.isEnabled(.artifactory)) + UserDefaults.standard.object(forKey: Self.mailroomChoiceDefaultsKey) as? Bool, + rampedOn: FeatureRamps.isEnabled(.mailroom)) var booted = loaded - booted.artifactoryEnabled = artifactory.enabled + booted.mailroomEnabled = mailroom.enabled settings = booted // The assignment above is this property's initial value, so no observer ran: the // ramp-resolved bit is saved by hand, and only when it differs from the file. - if artifactory.fileNeedsWrite { + if mailroom.fileNeedsWrite { GraphcodeSettingsStore.save(booted) } - artifactoryEnabled = artifactory.enabled + mailroomEnabled = mailroom.enabled let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" betaUpdates = @@ -74,24 +74,24 @@ final class SettingsModel { == .beta } - /// The Artifactory's boot decision, separated so tests can pin it without touching + /// The Mailroom's boot decision, separated so tests can pin it without touching /// `UserDefaults`, the settings file, or the bundle. /// /// An install that has never chosen boots on the ramp's answer — on everywhere since /// the board shipped, with `ramps.json` kept as the kill switch — and that answer has /// to reach `~/.graphcode/settings.json` when it differs, because the daemon enforces - /// `artifactoryEnabled` out of the file and cannot see ramps or `UserDefaults`. A + /// `mailroomEnabled` out of the file and cannot see ramps or `UserDefaults`. A /// recorded choice outranks the ramp from then on. The switch itself is always /// offered: it is a setting now, not a beta gate, and a person who finds the board /// too much turns it off here. Rewriting a file that already agrees is churn. - static func resolvesArtifactory( + static func resolvesMailroom( loaded: Bool, explicitChoice: Bool?, rampedOn: Bool - ) -> ArtifactoryResolution { + ) -> MailroomResolution { let enabled = explicitChoice ?? rampedOn - return ArtifactoryResolution(enabled: enabled, fileNeedsWrite: enabled != loaded) + return MailroomResolution(enabled: enabled, fileNeedsWrite: enabled != loaded) } - struct ArtifactoryResolution: Equatable { + struct MailroomResolution: Equatable { var enabled: Bool var fileNeedsWrite: Bool } diff --git a/graphcode/Sources/Features/Settings/SettingsView.swift b/graphcode/Sources/Features/Settings/SettingsView.swift index a4894c25..7c5fad2b 100644 --- a/graphcode/Sources/Features/Settings/SettingsView.swift +++ b/graphcode/Sources/Features/Settings/SettingsView.swift @@ -177,16 +177,16 @@ struct SettingsView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - // Always offered. The daemon-side bit lives in `artifactoryEnabled` + // Always offered. The daemon-side bit lives in `mailroomEnabled` // (`GraphcodeSettings`), on by default; a flip here is what turns the board // off for a person who finds it too much, and is remembered over any rollout. - Toggle("Artifactory", isOn: $model.artifactoryEnabled) + Toggle("Mailroom", isOn: $model.mailroomEnabled) Text( "Loops share a message board — a note dropped for whoever comes next, " + "discoverable by loops that didn't exist when it was written — " + "alongside the addressed `node send` and edges. It also appears in a " + "loop's workspace rail, where you can read it and leave notes yourself. " - + "Off, the daemon refuses every artifactory command and the rail section " + + "Off, the daemon refuses every mailroom command and the rail section " + "goes away. On by default; your choice here is kept." ) .font(.caption2) diff --git a/graphcode/Tests/FeatureRampsTests.swift b/graphcode/Tests/FeatureRampsTests.swift index 8fc98612..eb3ca7f5 100644 --- a/graphcode/Tests/FeatureRampsTests.swift +++ b/graphcode/Tests/FeatureRampsTests.swift @@ -79,26 +79,26 @@ struct FeatureRampsTests { } @Test - func artifactoryShipsOnEverywhere() { - // The Artifactory is ramped fully on, so — the codespaces rule — its baked default + func mailroomShipsOnEverywhere() { + // The Mailroom is ramped fully on, so — the codespaces rule — its baked default // moved up with it: an offline first launch on either channel gets the board, and // the served file is the kill switch rather than the opener. let id = UUID().uuidString #expect( - FeatureRamps.isEnabled(.artifactory, configuration: nil, channel: "beta", installID: id)) + FeatureRamps.isEnabled(.mailroom, configuration: nil, channel: "beta", installID: id)) #expect( - FeatureRamps.isEnabled(.artifactory, configuration: nil, channel: "stable", installID: id)) + FeatureRamps.isEnabled(.mailroom, configuration: nil, channel: "stable", installID: id)) // The fetched file stays both the opener and the kill switch either way: raised // to 100 everywhere it turns stable installs on, dropped to 0 it turns even beta // installs off. let everywhere = FeatureRamps.Configuration( - features: ["artifactory": ["beta": 100, "stable": 100]]) + features: ["mailroom": ["beta": 100, "stable": 100]]) #expect( FeatureRamps.isEnabled( - .artifactory, configuration: everywhere, channel: "stable", installID: id)) - let nowhere = FeatureRamps.Configuration(features: ["artifactory": ["beta": 0, "stable": 0]]) + .mailroom, configuration: everywhere, channel: "stable", installID: id)) + let nowhere = FeatureRamps.Configuration(features: ["mailroom": ["beta": 0, "stable": 0]]) #expect( !FeatureRamps.isEnabled( - .artifactory, configuration: nowhere, channel: "beta", installID: id)) + .mailroom, configuration: nowhere, channel: "beta", installID: id)) } } diff --git a/graphcode/Tests/ArtifactoryBudgetTests.swift b/graphcode/Tests/MailroomBudgetTests.swift similarity index 73% rename from graphcode/Tests/ArtifactoryBudgetTests.swift rename to graphcode/Tests/MailroomBudgetTests.swift index e2f6caac..70a037b8 100644 --- a/graphcode/Tests/ArtifactoryBudgetTests.swift +++ b/graphcode/Tests/MailroomBudgetTests.swift @@ -1,4 +1,4 @@ -import ArtifactoryKit +import MailroomKit import ComposableArchitecture import Foundation import GraphcodeKit @@ -8,7 +8,7 @@ import Testing /// ask that makes a resolving loop write something down — the review round that /// followed the independent read of #229. @Suite -struct ArtifactoryBudgetTests { +struct MailroomBudgetTests { private func makeStore( enabled: Bool = true, delivered: LockIsolated<[(UUID, String)]>? = nil, @@ -21,7 +21,7 @@ struct ArtifactoryBudgetTests { return true }, onAppendMemory: { nodeID, entry in memory?.withValue { $0.append((nodeID, entry)) } }, - onArtifactoryEnabled: { enabled }) + onMailroomEnabled: { enabled }) await store.handle( .createNode(NodeDraft(title: "Author", loopType: .turnBased, firstInstruction: "Work"))) await store.handle( @@ -43,15 +43,15 @@ struct ArtifactoryBudgetTests { let ids = await ids(store) await store.handle( - .artifactoryPost(text: "DEAD END: approach X fails", topic: "findings", from: ids[0])) - for index in 0..<(Artifactory.maxRecords * 4) { + .mailroomPost(text: "DEAD END: approach X fails", topic: "findings", from: ids[0])) + for index in 0..<(Mailroom.maxRecords * 4) { await store.handle( .messageNode(ids[1], text: "ping \(index)", from: ids[0], followUp: true)) } - let board = await store.graph.artifactory + let board = await store.graph.mailroom #expect(board.contains { $0.body.contains("DEAD END") }) - #expect(board.filter { $0.kind == .record }.count == Artifactory.maxRecords) + #expect(board.filter { $0.kind == .record }.count == Mailroom.maxRecords) #expect(board.filter { $0.kind == .note }.count == 1) } @@ -63,34 +63,34 @@ struct ArtifactoryBudgetTests { let ids = await ids(store) await store.handle(.messageNode(ids[1], text: "the API changed", from: ids[0], followUp: true)) - for index in 0..<(Artifactory.maxNotes + 10) { - await store.handle(.artifactoryPost(text: "note \(index)", topic: nil, from: ids[0])) + for index in 0..<(Mailroom.maxNotes + 10) { + await store.handle(.mailroomPost(text: "note \(index)", topic: nil, from: ids[0])) } - let board = await store.graph.artifactory + let board = await store.graph.mailroom #expect(board.filter { $0.kind == .record }.count == 1) - #expect(board.filter { $0.kind == .note }.count == Artifactory.maxNotes) + #expect(board.filter { $0.kind == .note }.count == Mailroom.maxNotes) // Ids still only grow, so no cursor mistakes an old post for new mail. - #expect(board.last?.id == Artifactory.maxNotes + 11) + #expect(board.last?.id == Mailroom.maxNotes + 11) } @Test func pruningKeepsTheBoardInOneSequence() { let base = Date() - var posts: [ArtifactoryPost] = [] - for index in 1...(Artifactory.maxRecords + 4) { + var posts: [MailroomPost] = [] + for index in 1...(Mailroom.maxRecords + 4) { posts.append( - ArtifactoryPost( + MailroomPost( id: index, at: base, authorID: nil, author: "a human", topic: nil, body: "r\(index)", kind: .record)) posts.append( - ArtifactoryPost( + MailroomPost( id: index + 1000, at: base, authorID: nil, author: "a human", topic: nil, body: "n\(index)", kind: .note)) } - let pruned = Artifactory.pruned(posts.sorted { $0.id < $1.id }) + let pruned = Mailroom.pruned(posts.sorted { $0.id < $1.id }) #expect(pruned == pruned.sorted { $0.id < $1.id }) - #expect(pruned.filter { $0.kind == .record }.count == Artifactory.maxRecords) + #expect(pruned.filter { $0.kind == .record }.count == Mailroom.maxRecords) } /// A board written before records had a kind decodes as all notes — everything on it @@ -100,7 +100,7 @@ struct ArtifactoryBudgetTests { let json = """ {"id":3,"at":747000000,"author":"Author","body":"hello"} """ - let post = try JSONDecoder().decode(ArtifactoryPost.self, from: Data(json.utf8)) + let post = try JSONDecoder().decode(MailroomPost.self, from: Data(json.utf8)) #expect(post.kind == .note) #expect(post.authorID == nil) } @@ -114,11 +114,11 @@ struct ArtifactoryBudgetTests { let store = await makeStore() let ids = await ids(store) await store.handle( - .artifactoryPost(text: "issue #12 is mine", topic: "claims", from: ids[0])) + .mailroomPost(text: "issue #12 is mine", topic: "claims", from: ids[0])) await store.handle(.deleteNode(ids[0])) - let board = await store.graph.artifactory + let board = await store.graph.mailroom #expect(board.count == 1) #expect(board[0].body == "issue #12 is mine") #expect(board[0].authorID == nil) @@ -131,15 +131,15 @@ struct ArtifactoryBudgetTests { func aLoopBornAfterTheAuthorsDeletionStillReadsTheNote() async { let store = await makeStore() let ids = await ids(store) - await store.handle(.artifactoryPost(text: "approach X fails", topic: nil, from: ids[0])) + await store.handle(.mailroomPost(text: "approach X fails", topic: nil, from: ids[0])) await store.handle(.deleteNode(ids[0])) await store.handle( .createNode(NodeDraft(title: "Successor", loopType: .turnBased, firstInstruction: "W"))) let graph = await store.graph let successor = graph.nodes.first { $0.title == "Successor" }! - let unread = Artifactory.unread( - in: graph.artifactory, since: successor.lastArtifactoryRead) + let unread = Mailroom.unread( + in: graph.mailroom, since: successor.lastMailroomRead) #expect(unread.map(\.body) == ["approach X fails"]) } @@ -151,17 +151,17 @@ struct ArtifactoryBudgetTests { let store = GraphStore( onEnsureSession: { _, _ in }, onRemoveMemory: { nodeID in removed.withValue { $0.append(nodeID) } }, - onArtifactoryEnabled: { true }) + onMailroomEnabled: { true }) await store.handle( .createNode(NodeDraft(title: "Author", loopType: .turnBased, firstInstruction: "W"))) let id = try #require(await store.graph.nodes.first?.id) - await store.handle(.artifactoryPost(text: "a note", topic: nil, from: id)) + await store.handle(.mailroomPost(text: "a note", topic: nil, from: id)) await store.handle(.deleteNode(id)) #expect(removed.value == [id]) #expect(await store.graph.nodes.isEmpty) - #expect(await store.graph.artifactory.count == 1) + #expect(await store.graph.mailroom.count == 1) } // MARK: - Self-triaging sync @@ -171,18 +171,18 @@ struct ArtifactoryBudgetTests { var graph = LoopGraph(project: ProjectRef(path: "/tmp/p", name: "p")) let reader = LoopNode(title: "Reader", loopType: .turnBased) graph.nodes.append(reader) - for index in 1...(Artifactory.triageAfterPosts + 1) { - graph.artifactory.append( - ArtifactoryPost( + for index in 1...(Mailroom.triageAfterPosts + 1) { + graph.mailroom.append( + MailroomPost( id: index, at: Date(), authorID: nil, author: "a human", topic: nil, body: "note \(index) with a body long enough to be worth truncating for triage")) } - let rendered = GraphcodeCommand.renderArtifactory( + let rendered = GraphcodeCommand.renderMailroom( graph, unreadFor: reader.id, autoTriage: true) #expect(rendered.contains("headlines only")) - #expect(rendered.contains("artifactory read /tmp/p ")) + #expect(rendered.contains("mailroom read /tmp/p ")) } @Test @@ -190,12 +190,12 @@ struct ArtifactoryBudgetTests { var graph = LoopGraph(project: ProjectRef(path: "/tmp/p", name: "p")) let reader = LoopNode(title: "Reader", loopType: .turnBased) graph.nodes.append(reader) - graph.artifactory.append( - ArtifactoryPost( + graph.mailroom.append( + MailroomPost( id: 1, at: Date(), authorID: nil, author: "a human", topic: nil, body: "short enough to read in full")) - let rendered = GraphcodeCommand.renderArtifactory( + let rendered = GraphcodeCommand.renderMailroom( graph, unreadFor: reader.id, autoTriage: true) #expect(rendered.contains("short enough to read in full")) @@ -207,51 +207,51 @@ struct ArtifactoryBudgetTests { @Test func aFewVeryLongNotesTriageOnBytes() { let posts = (1...5).map { index in - ArtifactoryPost( + MailroomPost( id: index, at: Date(), authorID: nil, author: "a human", topic: nil, body: String(repeating: "x", count: 1000)) } - #expect(Artifactory.needsTriage(posts)) - #expect(!Artifactory.needsTriage(Array(posts.prefix(1)))) + #expect(Mailroom.needsTriage(posts)) + #expect(!Mailroom.needsTriage(Array(posts.prefix(1)))) } @Test func syncParsesFullAndDefaultsToAutoTriage() throws { - let full = try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/p", "--full"]) + let full = try GraphcodeCommand.parse(["mailroom", "sync", "/tmp/p", "--full"]) #expect( full - == .artifactorySync( + == .mailroomSync( projectPath: "/tmp/p", headlines: false, mark: false, json: false, full: true)) - let plain = try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/p"]) + let plain = try GraphcodeCommand.parse(["mailroom", "sync", "/tmp/p"]) #expect( plain - == .artifactorySync( + == .mailroomSync( projectPath: "/tmp/p", headlines: false, mark: false, json: false, full: false)) } // MARK: - The write-side pull - /// Every other artifactory affordance is read-side. This is the one that asks a loop + /// Every other mailroom affordance is read-side. This is the one that asks a loop /// to write, at the one moment it knows what it learned. @Test func aResolvingLoopIsAskedToLeaveANote() { - let ask = MessageBus.resolutionAsk(distillSkill: false, artifactoryProjectPath: "/tmp/p") - #expect(ask?.contains("graphcode artifactory post /tmp/p") == true) + let ask = MessageBus.resolutionAsk(distillSkill: false, mailroomProjectPath: "/tmp/p") + #expect(ask?.contains("graphcode mailroom post /tmp/p") == true) #expect(ask?.hasPrefix("[graphcode] ") == true) } /// A goal loop that succeeded is owed both asks, and is interrupted once for them. @Test func bothAsksArriveAsOneInterruption() { - let ask = MessageBus.resolutionAsk(distillSkill: true, artifactoryProjectPath: "/tmp/p") + let ask = MessageBus.resolutionAsk(distillSkill: true, mailroomProjectPath: "/tmp/p") #expect(ask?.contains("distill it into a project skill") == true) - #expect(ask?.contains("graphcode artifactory post") == true) + #expect(ask?.contains("graphcode mailroom post") == true) #expect(ask?.components(separatedBy: "[graphcode] ").count == 2) } @Test func noBoardAndNoSkillMeansNoInterruption() { - #expect(MessageBus.resolutionAsk(distillSkill: false, artifactoryProjectPath: nil) == nil) + #expect(MessageBus.resolutionAsk(distillSkill: false, mailroomProjectPath: nil) == nil) } /// With the board off, a resolving loop is never pointed at a verb the daemon would @@ -265,7 +265,7 @@ struct ArtifactoryBudgetTests { delivered.withValue { $0.append((node.id, message)) } return true }, - onArtifactoryEnabled: { false }) + onMailroomEnabled: { false }) await store.handle( .createNode( NodeDraft( @@ -274,6 +274,6 @@ struct ArtifactoryBudgetTests { await store.handle(.nodeCheckApproved(id)) - #expect(!delivered.value.contains { $0.1.contains("artifactory post") }) + #expect(!delivered.value.contains { $0.1.contains("mailroom post") }) } } diff --git a/graphcode/Tests/ArtifactoryCommandTests.swift b/graphcode/Tests/MailroomCommandTests.swift similarity index 59% rename from graphcode/Tests/ArtifactoryCommandTests.swift rename to graphcode/Tests/MailroomCommandTests.swift index 611113e3..a406f2ea 100644 --- a/graphcode/Tests/ArtifactoryCommandTests.swift +++ b/graphcode/Tests/MailroomCommandTests.swift @@ -1,14 +1,14 @@ -import ArtifactoryKit +import MailroomKit import Foundation import GraphcodeKit import Testing -/// The `artifactory` verbs' CLI half: what each spelling parses into and what the board +/// The `mailroom` verbs' CLI half: what each spelling parses into and what the board /// renders as. The daemon half — posting, cursors, watcher wakes — lives in -/// `ArtifactoryTests`; here the question is what a loop or human types and what comes +/// `MailroomTests`; here the question is what a loop or human types and what comes /// back, because a malformed command must be a useful error, never a quiet no-op. @Suite -struct ArtifactoryCommandTests { +struct MailroomCommandTests { // MARK: Parsing @Test @@ -16,73 +16,73 @@ struct ArtifactoryCommandTests { // Lower-casing is the daemon's job (one spelling per topic across the graph); // the CLI carries what was typed. #expect( - try GraphcodeCommand.parse(["artifactory", "post", "/tmp/x", "staking", "issue", "#12"]) - == .artifactoryPost(projectPath: "/tmp/x", topic: nil, text: "staking issue #12")) + try GraphcodeCommand.parse(["mailroom", "post", "/tmp/x", "staking", "issue", "#12"]) + == .mailroomPost(projectPath: "/tmp/x", topic: nil, text: "staking issue #12")) #expect( try GraphcodeCommand.parse( - ["artifactory", "post", "/tmp/x", "--topic", "Claims", "staking", "issue", "#12"]) - == .artifactoryPost(projectPath: "/tmp/x", topic: "Claims", text: "staking issue #12")) + ["mailroom", "post", "/tmp/x", "--topic", "Claims", "staking", "issue", "#12"]) + == .mailroomPost(projectPath: "/tmp/x", topic: "Claims", text: "staking issue #12")) #expect( try GraphcodeCommand.parse( - ["artifactory", "post", "/tmp/x", "staking", "it", "--topic", "claims"]) - == .artifactoryPost(projectPath: "/tmp/x", topic: "claims", text: "staking it")) + ["mailroom", "post", "/tmp/x", "staking", "it", "--topic", "claims"]) + == .mailroomPost(projectPath: "/tmp/x", topic: "claims", text: "staking it")) } @Test func postWithoutANoteIsAMissingNote() { #expect(throws: GraphcodeCommand.ParseError.missingArgument("note")) { - try GraphcodeCommand.parse(["artifactory", "post", "/tmp/x"]) + try GraphcodeCommand.parse(["mailroom", "post", "/tmp/x"]) } // A topic with nothing to say about it is still nothing to post. #expect(throws: GraphcodeCommand.ParseError.missingArgument("note")) { - try GraphcodeCommand.parse(["artifactory", "post", "/tmp/x", "--topic", "build"]) + try GraphcodeCommand.parse(["mailroom", "post", "/tmp/x", "--topic", "build"]) } } @Test func syncAndListTakeOnlyAProjectPath() throws { #expect( - try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/x"]) - == .artifactorySync( + try GraphcodeCommand.parse(["mailroom", "sync", "/tmp/x"]) + == .mailroomSync( projectPath: "/tmp/x", headlines: false, mark: false, json: false, full: false)) #expect( - try GraphcodeCommand.parse(["artifactory", "list", "/tmp/x"]) - == .artifactoryList(projectPath: "/tmp/x", search: nil, json: false)) + try GraphcodeCommand.parse(["mailroom", "list", "/tmp/x"]) + == .mailroomList(projectPath: "/tmp/x", search: nil, json: false)) } @Test func watchDefaultsToEveryPostAndOptsOutWithOff() throws { #expect( - try GraphcodeCommand.parse(["artifactory", "watch", "/tmp/x"]) - == .artifactoryWatch(projectPath: "/tmp/x", on: true, topic: nil)) + try GraphcodeCommand.parse(["mailroom", "watch", "/tmp/x"]) + == .mailroomWatch(projectPath: "/tmp/x", on: true, topic: nil)) #expect( - try GraphcodeCommand.parse(["artifactory", "watch", "/tmp/x", "--topic", "build"]) - == .artifactoryWatch(projectPath: "/tmp/x", on: true, topic: "build")) + try GraphcodeCommand.parse(["mailroom", "watch", "/tmp/x", "--topic", "build"]) + == .mailroomWatch(projectPath: "/tmp/x", on: true, topic: "build")) #expect( - try GraphcodeCommand.parse(["artifactory", "watch", "/tmp/x", "--off"]) - == .artifactoryWatch(projectPath: "/tmp/x", on: false, topic: nil)) + try GraphcodeCommand.parse(["mailroom", "watch", "/tmp/x", "--off"]) + == .mailroomWatch(projectPath: "/tmp/x", on: false, topic: nil)) #expect( - try GraphcodeCommand.parse(["artifactory", "watch", "/tmp/x", "--topic", "build", "--off"]) - == .artifactoryWatch(projectPath: "/tmp/x", on: false, topic: "build")) + try GraphcodeCommand.parse(["mailroom", "watch", "/tmp/x", "--topic", "build", "--off"]) + == .mailroomWatch(projectPath: "/tmp/x", on: false, topic: "build")) } @Test func aMissingProjectPathIsNamedInTheError() { #expect(throws: GraphcodeCommand.ParseError.missingArgument("project-path")) { - try GraphcodeCommand.parse(["artifactory", "post"]) + try GraphcodeCommand.parse(["mailroom", "post"]) } #expect(throws: GraphcodeCommand.ParseError.missingArgument("project-path")) { - try GraphcodeCommand.parse(["artifactory", "watch", "--off"]) + try GraphcodeCommand.parse(["mailroom", "watch", "--off"]) } } @Test - func unknownArtifactoryVerbAndOptionAreNamed() { - #expect(throws: GraphcodeCommand.ParseError.unknownCommand("artifactory fetch")) { - try GraphcodeCommand.parse(["artifactory", "fetch", "/tmp/x"]) + func unknownMailroomVerbAndOptionAreNamed() { + #expect(throws: GraphcodeCommand.ParseError.unknownCommand("mailroom fetch")) { + try GraphcodeCommand.parse(["mailroom", "fetch", "/tmp/x"]) } #expect(throws: GraphcodeCommand.ParseError.unknownOption("--filter")) { - try GraphcodeCommand.parse(["artifactory", "list", "/tmp/x", "--filter", "auth"]) + try GraphcodeCommand.parse(["mailroom", "list", "/tmp/x", "--filter", "auth"]) } } @@ -91,12 +91,12 @@ struct ArtifactoryCommandTests { // The one moment a caller admits they don't know the arguments must not be the // one moment they are required to supply them — the rule `node create --help` // already established. - #expect(try GraphcodeCommand.parse(["artifactory", "--help"]) == .help) - #expect(try GraphcodeCommand.parse(["artifactory", "post", "--help"]) == .help) - #expect(try GraphcodeCommand.parse(["artifactory", "post", "/tmp/x", "-h"]) == .help) - #expect(try GraphcodeCommand.parse(["artifactory", "sync", "--help"]) == .help) - #expect(try GraphcodeCommand.parse(["artifactory", "list", "/tmp/x", "--help"]) == .help) - #expect(try GraphcodeCommand.parse(["artifactory", "watch", "--help"]) == .help) + #expect(try GraphcodeCommand.parse(["mailroom", "--help"]) == .help) + #expect(try GraphcodeCommand.parse(["mailroom", "post", "--help"]) == .help) + #expect(try GraphcodeCommand.parse(["mailroom", "post", "/tmp/x", "-h"]) == .help) + #expect(try GraphcodeCommand.parse(["mailroom", "sync", "--help"]) == .help) + #expect(try GraphcodeCommand.parse(["mailroom", "list", "/tmp/x", "--help"]) == .help) + #expect(try GraphcodeCommand.parse(["mailroom", "watch", "--help"]) == .help) } // MARK: Rendering @@ -104,16 +104,16 @@ struct ArtifactoryCommandTests { @Test func theBoardRendersOneLinePerPost() { var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) - graph.artifactory = [ - ArtifactoryPost( + graph.mailroom = [ + MailroomPost( id: 1, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", topic: nil, body: "kickoff"), - ArtifactoryPost( + MailroomPost( id: 4, at: Date(timeIntervalSince1970: 100), authorID: UUID(), author: "Author", topic: "claims", body: "issue #12 is mine"), ] - let rendered = GraphcodeCommand.renderArtifactory(graph) + let rendered = GraphcodeCommand.renderMailroom(graph) #expect(rendered.contains("#1 from a human")) #expect(rendered.contains("#4 (claims) from Author")) @@ -124,41 +124,41 @@ struct ArtifactoryCommandTests { func unreadForReaderUsesItsCursorNotTheCount() { var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) var reader = LoopNode(title: "Reader", loopType: .turnBased) - reader.lastArtifactoryRead = 1 + reader.lastMailroomRead = 1 graph.nodes.append(reader) - graph.artifactory = [ - ArtifactoryPost( + graph.mailroom = [ + MailroomPost( id: 1, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", topic: nil, body: "already read"), - ArtifactoryPost( + MailroomPost( id: 2, at: Date(timeIntervalSince1970: 1), authorID: nil, author: "a human", topic: nil, body: "still unread"), ] - let forReader = GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id) + let forReader = GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id) #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.renderArtifactory(graph, unreadFor: UUID()).contains("#1")) + #expect(GraphcodeCommand.renderMailroom(graph, unreadFor: UUID()).contains("#1")) } @Test func emptyBoardAndNothingUnreadSaySo() { var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) - #expect(GraphcodeCommand.renderArtifactory(graph).contains("the board is empty")) + #expect(GraphcodeCommand.renderMailroom(graph).contains("the board is empty")) var reader = LoopNode(title: "Reader", loopType: .turnBased) - reader.lastArtifactoryRead = 3 + reader.lastMailroomRead = 3 graph.nodes.append(reader) - graph.artifactory = [ - ArtifactoryPost( + graph.mailroom = [ + MailroomPost( id: 3, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", topic: nil, body: "caught up") ] - #expect(GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id) == "no unread posts") + #expect(GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id) == "no unread posts") } @Test @@ -166,8 +166,8 @@ struct ArtifactoryCommandTests { var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) #expect(GraphcodeCommand.renderPosted(graph) == "posted") - graph.artifactory = [ - ArtifactoryPost( + graph.mailroom = [ + MailroomPost( id: 7, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", topic: "build", body: "build is red") ] @@ -175,8 +175,8 @@ struct ArtifactoryCommandTests { } @Test - func helpTextTeachesTheArtifactoryVerbs() { - for verb in ["artifactory post", "artifactory sync", "artifactory list", "artifactory watch"] { + func helpTextTeachesTheMailroomVerbs() { + for verb in ["mailroom post", "mailroom sync", "mailroom list", "mailroom watch"] { #expect(GraphcodeCommand.helpText.contains(verb)) } } @@ -187,16 +187,16 @@ struct ArtifactoryCommandTests { private func boardWithPosts() -> (LoopGraph, LoopNode) { var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) var reader = LoopNode(title: "Reader", loopType: .turnBased) - reader.lastArtifactoryRead = 1 + reader.lastMailroomRead = 1 graph.nodes.append(reader) - graph.artifactory = [ - ArtifactoryPost( + graph.mailroom = [ + MailroomPost( id: 1, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", topic: nil, body: "already read"), - ArtifactoryPost( + MailroomPost( id: 2, at: Date(timeIntervalSince1970: 1), authorID: UUID(), author: "Author", topic: "build", body: String(repeating: "red ", count: 40)), - ArtifactoryPost( + MailroomPost( id: 3, at: Date(timeIntervalSince1970: 2), authorID: nil, author: "a human", topic: nil, body: "auth deadlock traced to token refresh"), ] @@ -205,22 +205,22 @@ private func boardWithPosts() -> (LoopGraph, LoopNode) { @Test func syncParsesItsReadModes() throws { - let plain = try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/x"]) + let plain = try GraphcodeCommand.parse(["mailroom", "sync", "/tmp/x"]) #expect( plain - == .artifactorySync( + == .mailroomSync( projectPath: "/tmp/x", headlines: false, mark: false, json: false, full: false)) let all = try GraphcodeCommand.parse([ - "artifactory", "sync", "/tmp/x", "--headlines", "--mark", "--json", + "mailroom", "sync", "/tmp/x", "--headlines", "--mark", "--json", ]) #expect( all - == .artifactorySync( + == .mailroomSync( projectPath: "/tmp/x", headlines: true, mark: true, json: true, full: false)) #expect { - try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/x", "--search", "x"]) + try GraphcodeCommand.parse(["mailroom", "sync", "/tmp/x", "--search", "x"]) } throws: { error in error as? GraphcodeCommand.ParseError == .unknownOption("--search") } @@ -229,18 +229,18 @@ func syncParsesItsReadModes() throws { @Test func readParsesAPostIDAndRejectsNonNumericOnes() throws { #expect( - try GraphcodeCommand.parse(["artifactory", "read", "/tmp/x", "7"]) - == .artifactoryRead(projectPath: "/tmp/x", postID: 7)) + try GraphcodeCommand.parse(["mailroom", "read", "/tmp/x", "7"]) + == .mailroomRead(projectPath: "/tmp/x", postID: 7)) #expect { - try GraphcodeCommand.parse(["artifactory", "read", "/tmp/x", "seven"]) + try GraphcodeCommand.parse(["mailroom", "read", "/tmp/x", "seven"]) } throws: { error in error as? GraphcodeCommand.ParseError == .invalidValue(argument: "post-id", value: "seven") } #expect { - try GraphcodeCommand.parse(["artifactory", "read", "/tmp/x"]) + try GraphcodeCommand.parse(["mailroom", "read", "/tmp/x"]) } throws: { error in error as? GraphcodeCommand.ParseError == .missingArgument("post-id") } @@ -249,21 +249,21 @@ func readParsesAPostIDAndRejectsNonNumericOnes() throws { @Test func listParsesSearchAndJSON() throws { #expect( - try GraphcodeCommand.parse(["artifactory", "list", "/tmp/x", "--search", "auth", "--json"]) - == .artifactoryList(projectPath: "/tmp/x", search: "auth", json: true)) + try GraphcodeCommand.parse(["mailroom", "list", "/tmp/x", "--search", "auth", "--json"]) + == .mailroomList(projectPath: "/tmp/x", search: "auth", json: true)) #expect( - try GraphcodeCommand.parse(["artifactory", "list", "/tmp/x"]) - == .artifactoryList(projectPath: "/tmp/x", search: nil, json: false)) + try GraphcodeCommand.parse(["mailroom", "list", "/tmp/x"]) + == .mailroomList(projectPath: "/tmp/x", search: nil, json: false)) } @Test func headlinesCutBodiesToATriageLine() { let (graph, reader) = boardWithPosts() - let headlines = GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id, headlines: true) + let headlines = GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id, 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.renderArtifactory(graph, unreadFor: reader.id) + let full = GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id) #expect(full.contains("red red")) } @@ -273,28 +273,28 @@ func searchFiltersWhatIsShownButNeverWhatIsRemembered() { // "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.renderArtifactory(graph, unreadFor: reader.id, search: "deadlock") + let filtered = GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id, search: "deadlock") #expect(filtered.contains("#3")) #expect(!filtered.contains("#2")) #expect( - GraphcodeCommand.renderArtifactory(graph, search: "nonesuch") + GraphcodeCommand.renderMailroom(graph, search: "nonesuch") .contains("no posts match 'nonesuch'")) #expect( - GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id, search: "nonesuch") + GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id, search: "nonesuch") .contains("no unread posts match 'nonesuch'")) } @Test func jsonRendersTheSameTruthInOtherSyntax() throws { struct Board: Decodable { - let posts: [ArtifactoryPost] + let posts: [MailroomPost] let lastRead: Int? } let (graph, reader) = boardWithPosts() let forReader = try #require( - GraphcodeCommand.renderArtifactoryJSON(graph, unreadFor: reader.id).data(using: .utf8)) + GraphcodeCommand.renderMailroomJSON(graph, unreadFor: reader.id).data(using: .utf8)) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let decoded = try decoder.decode(Board.self, from: forReader) @@ -302,7 +302,7 @@ func jsonRendersTheSameTruthInOtherSyntax() throws { #expect(decoded.posts.map(\.id) == [2, 3]) let whole = try #require( - GraphcodeCommand.renderArtifactoryJSON(graph).data(using: .utf8)) + GraphcodeCommand.renderMailroomJSON(graph).data(using: .utf8)) let everything = try decoder.decode(Board.self, from: whole) #expect(everything.posts.count == 3) #expect(everything.lastRead == nil) @@ -313,16 +313,16 @@ func statusLineCountsPostsAndUnreadOnlyWhenThereAreAny() { let (graph, reader) = boardWithPosts() #expect( - GraphcodeCommand.renderArtifactoryStatusLine(graph, readerID: reader.id) - == "artifactory: 3 posts, 2 unread for you") + GraphcodeCommand.renderMailroomStatusLine(graph, readerID: reader.id) + == "mailroom: 3 posts, 2 unread for you") #expect( - GraphcodeCommand.renderArtifactoryStatusLine(graph) == "artifactory: 3 posts") + GraphcodeCommand.renderMailroomStatusLine(graph) == "mailroom: 3 posts") #expect( - GraphcodeCommand.renderArtifactoryStatusLine( + GraphcodeCommand.renderMailroomStatusLine( LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x"))) == nil) - let rendered = GraphcodeCommand.render(graph, artifactoryReader: reader.id) - #expect(rendered.contains("artifactory: 3 posts, 2 unread for you")) + let rendered = GraphcodeCommand.render(graph, mailroomReader: reader.id) + #expect(rendered.contains("mailroom: 3 posts, 2 unread for you")) } // MARK: Read-side review round (status-line blast radius, json+search, boundaries) @@ -330,24 +330,24 @@ func statusLineCountsPostsAndUnreadOnlyWhenThereAreAny() { @Test func statusLineSurvivesAnEmptyNodeGraphWithHumanPosts() { var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) - graph.artifactory = [ - ArtifactoryPost( + graph.mailroom = [ + MailroomPost( id: 1, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", topic: nil, body: "written after the last loop was deleted") ] let rendered = GraphcodeCommand.render(graph) #expect(rendered.contains("no loops yet")) - #expect(rendered.contains("artifactory: 1 post")) + #expect(rendered.contains("mailroom: 1 post")) } @Test -func neverTouchedBoardRendersExactlyAsBeforeTheArtifactory() { +func neverTouchedBoardRendersExactlyAsBeforeTheMailroom() { var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) graph.nodes.append(LoopNode(title: "Solo", loopType: .turnBased, firstInstruction: "Work")) - let rendered = GraphcodeCommand.render(graph, artifactoryReader: UUID()) - #expect(!rendered.contains("artifactory:")) + let rendered = GraphcodeCommand.render(graph, mailroomReader: UUID()) + #expect(!rendered.contains("mailroom:")) #expect(!rendered.contains("\n edges:")) } @@ -357,19 +357,19 @@ func foreignReaderGetsThePlainCountLikeAHuman() { // The daemon refuses sync for a reader absent from this graph; the status line // claims no "unread for you" for one either. #expect( - GraphcodeCommand.renderArtifactoryStatusLine(graph, readerID: UUID()) - == "artifactory: 3 posts") + GraphcodeCommand.renderMailroomStatusLine(graph, readerID: UUID()) + == "mailroom: 3 posts") } @Test func listJSONHonorsTheSearchFilter() throws { struct Board: Decodable { - let posts: [ArtifactoryPost] + let posts: [MailroomPost] } let (graph, _) = boardWithPosts() let filtered = try #require( - GraphcodeCommand.renderArtifactoryJSON(graph, search: "deadlock").data(using: .utf8)) + GraphcodeCommand.renderMailroomJSON(graph, search: "deadlock").data(using: .utf8)) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 #expect(try decoder.decode(Board.self, from: filtered).posts.map(\.id) == [3]) @@ -378,11 +378,11 @@ func listJSONHonorsTheSearchFilter() throws { @Test func jsonDatesAreISOTwo8601NotTheEncoderDefault() throws { struct Board: Decodable { - let posts: [ArtifactoryPost] + let posts: [MailroomPost] } let (graph, _) = boardWithPosts() let data = try #require( - GraphcodeCommand.renderArtifactoryJSON(graph).data(using: .utf8)) + GraphcodeCommand.renderMailroomJSON(graph).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. @@ -398,12 +398,12 @@ func headlineTruncatesOnlyPastTheBoundaryAndStaysOneLine() { // body so the full line lands exactly at 80, then at 81. let at = Date(timeIntervalSince1970: 0) let prefix = GraphcodeCommand.render( - ArtifactoryPost(id: 1, at: at, authorID: nil, author: "a human", topic: nil, body: "") + MailroomPost(id: 1, at: at, authorID: nil, author: "a human", topic: nil, body: "") ) - let exact = ArtifactoryPost( + let exact = MailroomPost( id: 1, at: at, authorID: nil, author: "a human", topic: nil, body: String(repeating: "a", count: 80 - prefix.count)) - let over = ArtifactoryPost( + let over = MailroomPost( id: 1, at: at, authorID: nil, author: "a human", topic: nil, body: String(repeating: "a", count: 81 - prefix.count)) diff --git a/graphcode/Tests/ArtifactoryRailShareTests.swift b/graphcode/Tests/MailroomRailShareTests.swift similarity index 69% rename from graphcode/Tests/ArtifactoryRailShareTests.swift rename to graphcode/Tests/MailroomRailShareTests.swift index 2e5bb8be..d83c5994 100644 --- a/graphcode/Tests/ArtifactoryRailShareTests.swift +++ b/graphcode/Tests/MailroomRailShareTests.swift @@ -6,23 +6,23 @@ import Testing /// The board's scroll box is capped by a share of the rail, so a rigid box can never be /// what pushes the foot of the rail off a short window. @Suite -struct ArtifactoryRailShareTests { +struct MailroomRailShareTests { @Test func theBoardTakesAShareOfTheRailNotAFixedHeight() { // A 900pt rail: 40% is 360, well under the 600 ceiling and well over the floor. - #expect(LoopWorkspaceRail.artifactoryHeightCap(railHeight: 900) == 360) + #expect(LoopWorkspaceRail.mailroomHeightCap(railHeight: 900) == 360) // A tall rail: the share would exceed the ceiling, so the ceiling wins — ten posts // is enough on any window. - #expect(LoopWorkspaceRail.artifactoryHeightCap(railHeight: 2000) == 600) - #expect(ArtifactorySection.maxScrollHeight == 600) + #expect(LoopWorkspaceRail.mailroomHeightCap(railHeight: 2000) == 600) + #expect(MailroomSection.maxScrollHeight == 600) } /// A very short rail still shows a couple of posts rather than a sliver; below the /// floor the rail has bigger problems than this section. @Test func aShortRailStillShowsSomething() { - #expect(LoopWorkspaceRail.artifactoryHeightCap(railHeight: 300) == 160) - #expect(LoopWorkspaceRail.artifactoryHeightCap(railHeight: 0) == 160) + #expect(LoopWorkspaceRail.mailroomHeightCap(railHeight: 300) == 160) + #expect(LoopWorkspaceRail.mailroomHeightCap(railHeight: 0) == 160) } /// The share leaves room for everything rigid above it: THIS LOOP (118) plus the @@ -30,7 +30,7 @@ struct ArtifactoryRailShareTests { @Test func theShareLeavesRoomForTheRigidSections() { let rail: CGFloat = 700 - let board = LoopWorkspaceRail.artifactoryHeightCap(railHeight: rail) + let board = LoopWorkspaceRail.mailroomHeightCap(railHeight: rail) let rigidAbove: CGFloat = 118 + 120 + 24 + 24 + 12 * 2 #expect(board + rigidAbove < rail) } diff --git a/graphcode/Tests/ArtifactorySinceYouLookedTests.swift b/graphcode/Tests/MailroomSinceYouLookedTests.swift similarity index 73% rename from graphcode/Tests/ArtifactorySinceYouLookedTests.swift rename to graphcode/Tests/MailroomSinceYouLookedTests.swift index 428e544a..f64cd506 100644 --- a/graphcode/Tests/ArtifactorySinceYouLookedTests.swift +++ b/graphcode/Tests/MailroomSinceYouLookedTests.swift @@ -1,4 +1,4 @@ -import ArtifactoryKit +import MailroomKit import ComposableArchitecture import Foundation import GraphcodeKit @@ -6,10 +6,10 @@ import Testing @testable import graphcode -/// `SINCE YOU LOOKED` in the rail's ARTIFACTORY section is about the person at the +/// `SINCE YOU LOOKED` in the rail's MAILROOM section is about the person at the /// screen, exactly as it is two sections up — never the loop's own sync cursor. @Suite -struct ArtifactorySinceYouLookedTests { +struct MailroomSinceYouLookedTests { private let projectPath = "/tmp/since-you-looked-\(UUID().uuidString)" private func makeStore( @@ -20,7 +20,7 @@ struct ArtifactorySinceYouLookedTests { node: node, graph: graph, layout: .defaultLayout(forNode: node.id), projectPath: projectPath, projectName: "p") state.isRailVisible = railVisible - state.isArtifactoryFolded = folded + state.isMailroomFolded = folded let directory = FileManager.default.temporaryDirectory .appendingPathComponent("graphcode-tests-\(UUID().uuidString)", isDirectory: true) let store = TestStore(initialState: state) { @@ -34,10 +34,10 @@ struct ArtifactorySinceYouLookedTests { private func makeGraph(noteIDs: [Int], loopCursor: Int?) -> LoopGraph { var graph = LoopGraph(project: ProjectRef(path: projectPath, name: "p")) - graph.nodes.append(LoopNode(title: "Worker", lastArtifactoryRead: loopCursor)) + graph.nodes.append(LoopNode(title: "Worker", lastMailroomRead: loopCursor)) for id in noteIDs { - graph.artifactory.append( - ArtifactoryPost( + graph.mailroom.append( + MailroomPost( id: id, at: Date(), authorID: nil, author: "a human", topic: nil, body: "n\(id)")) } return graph @@ -47,9 +47,9 @@ struct ArtifactorySinceYouLookedTests { @Test func unreadIsTheHumansNotTheLoops() { let graph = makeGraph(noteIDs: [1, 2, 3], loopCursor: 3) - #expect(ArtifactoryPresentation.unreadNoteCount(graph: graph, seenPostID: nil) == 3) - #expect(ArtifactoryPresentation.unreadNoteCount(graph: graph, seenPostID: 2) == 1) - #expect(ArtifactoryPresentation.unreadNoteCount(graph: graph, seenPostID: 3) == 0) + #expect(MailroomPresentation.unreadNoteCount(graph: graph, seenPostID: nil) == 3) + #expect(MailroomPresentation.unreadNoteCount(graph: graph, seenPostID: 2) == 1) + #expect(MailroomPresentation.unreadNoteCount(graph: graph, seenPostID: 3) == 0) } @Test @@ -59,8 +59,8 @@ struct ArtifactorySinceYouLookedTests { await store.send(.workspaceLeft) - #expect(store.state.seenArtifactoryPostID == 3) - #expect(LoopWorkspaceRail.loadSeenArtifactoryPost(forProjectPath: projectPath) == 3) + #expect(store.state.seenMailroomPostID == 3) + #expect(LoopWorkspaceRail.loadSeenMailroomPost(forProjectPath: projectPath) == 3) } /// A hidden rail showed no posts; leaving must not clear a badge nobody could read. @@ -72,8 +72,8 @@ struct ArtifactorySinceYouLookedTests { await store.send(.workspaceLeft) - #expect(store.state.seenArtifactoryPostID == nil) - #expect(LoopWorkspaceRail.loadSeenArtifactoryPost(forProjectPath: projectPath) == nil) + #expect(store.state.seenMailroomPostID == nil) + #expect(LoopWorkspaceRail.loadSeenMailroomPost(forProjectPath: projectPath) == nil) } @Test @@ -84,7 +84,7 @@ struct ArtifactorySinceYouLookedTests { await store.send(.workspaceLeft) - #expect(store.state.seenArtifactoryPostID == nil) + #expect(store.state.seenMailroomPostID == nil) } /// A record (mirrored `node send`) is not a note: it is folded away, so it must not @@ -93,14 +93,14 @@ struct ArtifactorySinceYouLookedTests { @MainActor func lookedAdvancesToTheNewestNoteNotTheNewestRecord() async { var graph = makeGraph(noteIDs: [1, 2], loopCursor: nil) - graph.artifactory.append( - ArtifactoryPost( + graph.mailroom.append( + MailroomPost( id: 3, at: Date(), authorID: nil, author: "a human", topic: "direct", body: "@Worker: hi", kind: .record)) let store = makeStore(graph: graph, railVisible: true) await store.send(.workspaceLeft) - #expect(store.state.seenArtifactoryPostID == 2) + #expect(store.state.seenMailroomPostID == 2) } } diff --git a/graphcode/Tests/ArtifactoryTests.swift b/graphcode/Tests/MailroomTests.swift similarity index 65% rename from graphcode/Tests/ArtifactoryTests.swift rename to graphcode/Tests/MailroomTests.swift index dfe79514..7a21d7f4 100644 --- a/graphcode/Tests/ArtifactoryTests.swift +++ b/graphcode/Tests/MailroomTests.swift @@ -1,14 +1,14 @@ -import ArtifactoryKit +import MailroomKit import ComposableArchitecture import Foundation import GraphcodeKit import Testing -/// The Artifactory's daemon half: posting, cursors, subscriptions and watcher wakes. +/// The Mailroom's daemon half: posting, cursors, subscriptions and watcher wakes. /// Runs against a bare `GraphStore` with injected closures — no daemon, no socket, /// no zmx — the same harness `GraphStoreTests` uses. @Suite -struct ArtifactoryTests { +struct MailroomTests { /// Two loops to talk about: an author and a reader. Turn-based so nothing /// auto-starts a session. private func makeStore( @@ -31,7 +31,7 @@ struct ArtifactoryTests { memory?.withValue { $0.append((nodeID, entry)) } }, onAnnounceError: { message in errors?.withValue { $0.append(message) } }, - onArtifactoryEnabled: { enabled }) + onMailroomEnabled: { enabled }) await store.handle( .createNode(NodeDraft(title: "Author", loopType: .turnBased, firstInstruction: "Work"))) await store.handle( @@ -47,11 +47,11 @@ struct ArtifactoryTests { let ids = nodeIDs(await store.graph) await store.handle( - .artifactoryPost(text: " issue #12 is mine ", topic: "Claims", from: ids[0])) + .mailroomPost(text: " issue #12 is mine ", topic: "Claims", from: ids[0])) let graph = await store.graph - #expect(graph.artifactory.count == 1) - let post = graph.artifactory[0] + #expect(graph.mailroom.count == 1) + let post = graph.mailroom[0] #expect(post.id == 1) #expect(post.author == "Author") #expect(post.authorID == ids[0]) @@ -64,10 +64,10 @@ struct ArtifactoryTests { let store = await makeStore(enabled: false) let ids = nodeIDs(await store.graph) - await store.handle(.artifactoryPost(text: "hello", topic: nil, from: ids[0])) + await store.handle(.mailroomPost(text: "hello", topic: nil, from: ids[0])) let graph = await store.graph - #expect(graph.artifactory.isEmpty) + #expect(graph.mailroom.isEmpty) } @Test @@ -75,15 +75,15 @@ struct ArtifactoryTests { let store = await makeStore() let ids = nodeIDs(await store.graph) - await store.handle(.artifactoryPost(text: " ", topic: nil, from: ids[0])) + await store.handle(.mailroomPost(text: " ", topic: nil, from: ids[0])) await store.handle( - .artifactoryPost(text: String(repeating: "x", count: 2000), topic: nil, from: ids[0])) + .mailroomPost(text: String(repeating: "x", count: 2000), topic: nil, from: ids[0])) await store.handle( - .artifactoryPost(text: "ok", topic: String(repeating: "t", count: 100), from: ids[0])) - await store.handle(.artifactoryPost(text: "ok", topic: " ", from: ids[0])) + .mailroomPost(text: "ok", topic: String(repeating: "t", count: 100), from: ids[0])) + await store.handle(.mailroomPost(text: "ok", topic: " ", from: ids[0])) let graph = await store.graph - #expect(graph.artifactory.isEmpty) + #expect(graph.mailroom.isEmpty) } @Test @@ -91,14 +91,14 @@ struct ArtifactoryTests { let store = await makeStore() let ids = nodeIDs(await store.graph) - for index in 0..<(Artifactory.maxNotes + 5) { - await store.handle(.artifactoryPost(text: "post \(index)", topic: nil, from: ids[0])) + for index in 0..<(Mailroom.maxNotes + 5) { + await store.handle(.mailroomPost(text: "post \(index)", topic: nil, from: ids[0])) } let graph = await store.graph - #expect(graph.artifactory.count == Artifactory.maxNotes) - #expect(graph.artifactory.first?.body == "post 5") - #expect(graph.artifactory.last?.id == Artifactory.maxNotes + 5) + #expect(graph.mailroom.count == Mailroom.maxNotes) + #expect(graph.mailroom.first?.body == "post 5") + #expect(graph.mailroom.last?.id == Mailroom.maxNotes + 5) } @Test @@ -106,24 +106,24 @@ struct ArtifactoryTests { let store = await makeStore() let ids = nodeIDs(await store.graph) - await store.handle(.artifactoryPost(text: "one", topic: nil, from: ids[0])) - await store.handle(.artifactorySync(from: ids[1])) + await store.handle(.mailroomPost(text: "one", topic: nil, from: ids[0])) + await store.handle(.mailroomSync(from: ids[1])) var graph = await store.graph - #expect(graph.nodes[id: ids[1]]?.lastArtifactoryRead == 1) + #expect(graph.nodes[id: ids[1]]?.lastMailroomRead == 1) - await store.handle(.artifactorySync(from: ids[1])) + await store.handle(.mailroomSync(from: ids[1])) graph = await store.graph - #expect(graph.nodes[id: ids[1]]?.lastArtifactoryRead == 1) + #expect(graph.nodes[id: ids[1]]?.lastMailroomRead == 1) } @Test func syncNeedsLoopIdentity() async { let store = await makeStore() - await store.handle(.artifactorySync(from: nil)) + await store.handle(.mailroomSync(from: nil)) let graph = await store.graph - #expect(graph.nodes.allSatisfy { $0.lastArtifactoryRead == nil }) + #expect(graph.nodes.allSatisfy { $0.lastMailroomRead == nil }) } @Test @@ -131,13 +131,13 @@ struct ArtifactoryTests { let store = await makeStore() let ids = nodeIDs(await store.graph) - await store.handle(.artifactoryWatch(on: true, topic: "Build", from: ids[1])) + await store.handle(.mailroomWatch(on: true, topic: "Build", from: ids[1])) var graph = await store.graph - #expect(graph.nodes[id: ids[1]]?.artifactoryWatch == ArtifactoryWatch(topic: "build")) + #expect(graph.nodes[id: ids[1]]?.mailroomWatch == MailroomWatch(topic: "build")) - await store.handle(.artifactoryWatch(on: false, topic: nil, from: ids[1])) + await store.handle(.mailroomWatch(on: false, topic: nil, from: ids[1])) graph = await store.graph - #expect(graph.nodes[id: ids[1]]?.artifactoryWatch == nil) + #expect(graph.nodes[id: ids[1]]?.mailroomWatch == nil) } @Test @@ -147,15 +147,15 @@ struct ArtifactoryTests { let memory = LockIsolated<[(UUID, String)]>([]) let store = await makeStore(memory: memory) let ids = nodeIDs(await store.graph) - await store.handle(.artifactoryWatch(on: true, topic: "build", from: ids[1])) + await store.handle(.mailroomWatch(on: true, topic: "build", from: ids[1])) - await store.handle(.artifactoryPost(text: "build is red", topic: "build", from: ids[0])) + await store.handle(.mailroomPost(text: "build is red", topic: "build", from: ids[0])) let staged = memory.value.filter { - $0.0 == ids[1] && $0.1.contains("artifactory — new post #1 (build) from Author") + $0.0 == ids[1] && $0.1.contains("mailroom — new post #1 (build) from Author") } #expect(staged.count == 1) - #expect(staged[0].1.contains("graphcode artifactory sync")) + #expect(staged[0].1.contains("graphcode mailroom sync")) } @Test @@ -163,14 +163,14 @@ struct ArtifactoryTests { let memory = LockIsolated<[(UUID, String)]>([]) let store = await makeStore(memory: memory) let ids = nodeIDs(await store.graph) - await store.handle(.artifactoryWatch(on: true, topic: "build", from: ids[1])) + await store.handle(.mailroomWatch(on: true, topic: "build", from: ids[1])) - await store.handle(.artifactoryPost(text: "unrelated", topic: "auth", from: ids[0])) - await store.handle(.artifactoryWatch(on: false, topic: nil, from: ids[1])) - await store.handle(.artifactoryPost(text: "again", topic: "build", from: ids[0])) + await store.handle(.mailroomPost(text: "unrelated", topic: "auth", from: ids[0])) + await store.handle(.mailroomWatch(on: false, topic: nil, from: ids[1])) + await store.handle(.mailroomPost(text: "again", topic: "build", from: ids[0])) #expect( - memory.value.filter { $0.0 == ids[1] && $0.1.contains("artifactory — new post") } + memory.value.filter { $0.0 == ids[1] && $0.1.contains("mailroom — new post") } .isEmpty) } @@ -179,57 +179,57 @@ struct ArtifactoryTests { let memory = LockIsolated<[(UUID, String)]>([]) let store = await makeStore(memory: memory) let ids = nodeIDs(await store.graph) - await store.handle(.artifactoryWatch(on: true, topic: nil, from: ids[1])) + await store.handle(.mailroomWatch(on: true, topic: nil, from: ids[1])) - await store.handle(.artifactoryPost(text: "a", topic: "auth", from: ids[0])) - await store.handle(.artifactoryPost(text: "b", topic: nil, from: ids[0])) + await store.handle(.mailroomPost(text: "a", topic: "auth", from: ids[0])) + await store.handle(.mailroomPost(text: "b", topic: nil, from: ids[0])) #expect( - memory.value.filter { $0.0 == ids[1] && $0.1.contains("artifactory — new post") } + memory.value.filter { $0.0 == ids[1] && $0.1.contains("mailroom — new post") } .count == 2) } @Test - func graphRoundTripsArtifactoryThroughCodable() throws { + func graphRoundTripsMailroomThroughCodable() throws { var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) - graph.artifactory = [ - ArtifactoryPost( + graph.mailroom = [ + MailroomPost( id: 3, at: Date(timeIntervalSince1970: 100), authorID: nil, author: "a human", topic: "t", body: "b") ] var node = LoopNode(title: "n", loopType: .turnBased) - node.lastArtifactoryRead = 3 - node.artifactoryWatch = ArtifactoryWatch(topic: "t") + node.lastMailroomRead = 3 + node.mailroomWatch = MailroomWatch(topic: "t") graph.nodes.append(node) let data = try JSONEncoder().encode(graph) let decoded = try JSONDecoder().decode(LoopGraph.self, from: data) - #expect(decoded.artifactory == graph.artifactory) - #expect(decoded.nodes[0].lastArtifactoryRead == 3) - #expect(decoded.nodes[0].artifactoryWatch == ArtifactoryWatch(topic: "t")) + #expect(decoded.mailroom == graph.mailroom) + #expect(decoded.nodes[0].lastMailroomRead == 3) + #expect(decoded.nodes[0].mailroomWatch == MailroomWatch(topic: "t")) - // Graphs saved before the Artifactory decode with an empty board and no cursors: + // Graphs saved before the Mailroom decode with an empty board and no cursors: // take a fresh encoding and strip the new keys, reproducing an old file. let raw = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) var stripped = raw - stripped.removeValue(forKey: "artifactory") + stripped.removeValue(forKey: "mailroom") var nodes = try #require(raw["nodes"] as? [[String: Any]]) - nodes[0].removeValue(forKey: "lastArtifactoryRead") - nodes[0].removeValue(forKey: "artifactoryWatch") + nodes[0].removeValue(forKey: "lastMailroomRead") + nodes[0].removeValue(forKey: "mailroomWatch") stripped["nodes"] = nodes let legacyData = try JSONSerialization.data(withJSONObject: stripped) let old = try JSONDecoder().decode(LoopGraph.self, from: legacyData) - #expect(old.artifactory.isEmpty) - #expect(old.nodes[0].lastArtifactoryRead == nil) - #expect(old.nodes[0].artifactoryWatch == nil) + #expect(old.mailroom.isEmpty) + #expect(old.nodes[0].lastMailroomRead == nil) + #expect(old.nodes[0].mailroomWatch == nil) } } /// The mirroring and deletion halves live in an extension to keep the suite under swiftlint's body-length bound. -extension ArtifactoryTests { // MARK: - Shared-communication mirroring +extension MailroomTests { // MARK: - Shared-communication mirroring @Test - func directMessageMirrorsOntoArtifactory() async { + func directMessageMirrorsOntoMailroom() async { let store = await makeStore() let ids = nodeIDs(await store.graph) @@ -237,8 +237,8 @@ extension ArtifactoryTests { // MARK: - Shared-communication mirroring .messageNode(ids[1], text: "the API changed under you", from: ids[0], followUp: nil)) let graph = await store.graph - #expect(graph.artifactory.count == 1) - let record = graph.artifactory[0] + #expect(graph.mailroom.count == 1) + let record = graph.mailroom[0] #expect(record.topic == "direct") #expect(record.author == "Author") #expect(record.body == "@Reader: the API changed under you") @@ -249,18 +249,18 @@ extension ArtifactoryTests { // MARK: - Shared-communication mirroring let memory = LockIsolated<[(UUID, String)]>([]) let store = await makeStore(memory: memory) let ids = nodeIDs(await store.graph) - await store.handle(.artifactoryWatch(on: true, topic: "build", from: ids[1])) + await store.handle(.mailroomWatch(on: true, topic: "build", from: ids[1])) - await store.handle(.artifactoryPost(text: "build is red", topic: "build", from: ids[0])) + await store.handle(.mailroomPost(text: "build is red", topic: "build", from: ids[0])) // One post — the wake staged to the watcher must not become a post of its own. let graph = await store.graph - #expect(graph.artifactory.count == 1) - #expect(memory.value.contains { $0.0 == ids[1] && $0.1.contains("artifactory — new post #1") }) + #expect(graph.mailroom.count == 1) + #expect(memory.value.contains { $0.0 == ids[1] && $0.1.contains("mailroom — new post #1") }) } @Test - func deliveredMessageEdgeMirrorsOntoArtifactory() async { + func deliveredMessageEdgeMirrorsOntoMailroom() async { let delivered = LockIsolated<[(UUID, String)]>([]) let store = await makeStore(delivered: delivered) let ids = nodeIDs(await store.graph) @@ -273,14 +273,14 @@ extension ArtifactoryTests { // MARK: - Shared-communication mirroring let graph = await store.graph #expect(graph.edges[0].fired) - let records = graph.artifactory.filter { $0.topic == "direct" } + let records = graph.mailroom.filter { $0.topic == "direct" } #expect(records.count == 1) #expect(records[0].author == "Author") #expect(records[0].body == "@Reader: specs moved to docs/api.md") } @Test - func handoffMirrorsOntoArtifactoryWithPayload() async { + func handoffMirrorsOntoMailroomWithPayload() async { let store = await makeStore() let ids = nodeIDs(await store.graph) @@ -291,7 +291,7 @@ extension ArtifactoryTests { // MARK: - Shared-communication mirroring await store.handle(.nodeCheckApproved(ids[0])) let graph = await store.graph - let records = graph.artifactory.filter { $0.topic == "handoff" } + let records = graph.mailroom.filter { $0.topic == "handoff" } #expect(records.count == 1) #expect(records[0].author == "Author") #expect( @@ -308,20 +308,20 @@ extension ArtifactoryTests { // MARK: - Shared-communication mirroring func deletingALoopKeepsItsPostsAndTakesTheirHandle() async { let store = await makeStore() let ids = nodeIDs(await store.graph) - await store.handle(.artifactoryPost(text: "mine", topic: nil, from: ids[0])) - await store.handle(.artifactoryPost(text: "theirs", topic: nil, from: ids[1])) + await store.handle(.mailroomPost(text: "mine", topic: nil, from: ids[0])) + await store.handle(.mailroomPost(text: "theirs", topic: nil, from: ids[1])) await store.handle(.deleteNode(ids[0])) let graph = await store.graph #expect(graph.nodes.count == 1) - #expect(graph.artifactory.map(\.body) == ["mine", "theirs"]) - let orphaned = graph.artifactory[0] + #expect(graph.mailroom.map(\.body) == ["mine", "theirs"]) + let orphaned = graph.mailroom[0] #expect(orphaned.authorID == nil) #expect(orphaned.author == "Author (deleted)") // The surviving loop's own post is untouched — attribution and all. - #expect(graph.artifactory[1].authorID == ids[1]) - #expect(graph.artifactory[1].author == "Reader") + #expect(graph.mailroom[1].authorID == ids[1]) + #expect(graph.mailroom[1].author == "Reader") } @Test @@ -329,14 +329,14 @@ extension ArtifactoryTests { // MARK: - Shared-communication mirroring let store = await makeStore() let ids = nodeIDs(await store.graph) await store.handle(.messageNode(ids[1], text: "for you", from: ids[0], followUp: nil)) - #expect(await store.graph.artifactory.count == 1) + #expect(await store.graph.mailroom.count == 1) await store.handle(.deleteNode(ids[1])) let graph = await store.graph // The record of what was said stays; only the departed loop's own words go. - #expect(graph.artifactory.count == 1) - #expect(graph.artifactory[0].body == "@Reader: for you") + #expect(graph.mailroom.count == 1) + #expect(graph.mailroom[0].body == "@Reader: for you") } @Test @@ -349,8 +349,8 @@ extension ArtifactoryTests { // MARK: - Shared-communication mirroring title: "Child", loopType: .turnBased, firstInstruction: "Work", createdBy: ids[0]))) let childID = try #require((await store.graph.nodes.first { $0.createdBy == ids[0] })?.id) - await store.handle(.artifactoryPost(text: "child note", topic: nil, from: childID)) - await store.handle(.artifactoryPost(text: "parent note", topic: nil, from: ids[0])) + await store.handle(.mailroomPost(text: "child note", topic: nil, from: childID)) + await store.handle(.mailroomPost(text: "parent note", topic: nil, from: ids[0])) await store.handle(.deleteNode(ids[0])) @@ -359,28 +359,28 @@ extension ArtifactoryTests { // MARK: - Shared-communication mirroring // with the handles taken off — the descendants' words outlive them the same way. // Author and Child are gone; Reader, who was never in the custody chain, is not. #expect(graph.nodes.map(\.title) == ["Reader"]) - #expect(graph.artifactory.map(\.body) == ["child note", "parent note"]) - #expect(graph.artifactory.allSatisfy { $0.authorID == nil }) - #expect(graph.artifactory.map(\.author) == ["Child (deleted)", "Author (deleted)"]) + #expect(graph.mailroom.map(\.body) == ["child note", "parent note"]) + #expect(graph.mailroom.allSatisfy { $0.authorID == nil }) + #expect(graph.mailroom.map(\.author) == ["Child (deleted)", "Author (deleted)"]) } } /// The review round (PR #229): refusals announce, bounds bind, composites inherit the /// gate, imports start clean, and the briefing/digest announce the board exactly when /// the daemon will honour it. -extension ArtifactoryTests { +extension MailroomTests { @Test func refusalAnnouncesItselfInsteadOfStayingSilent() async { let errors = LockIsolated<[String]>([]) let store = await makeStore(enabled: false, errors: errors) let ids = nodeIDs(await store.graph) - await store.handle(.artifactoryPost(text: "hello", topic: nil, from: ids[0])) + await store.handle(.mailroomPost(text: "hello", topic: nil, from: ids[0])) let graph = await store.graph - #expect(graph.artifactory.isEmpty) + #expect(graph.mailroom.isEmpty) #expect(errors.value.count == 1) - #expect(errors.value[0].contains("Artifactory is off")) + #expect(errors.value[0].contains("Mailroom is off")) } @Test @@ -392,8 +392,8 @@ extension ArtifactoryTests { .messageNode(ids[1], text: String(repeating: "x", count: 3000), from: ids[0], followUp: nil)) let graph = await store.graph - let record = try #require(graph.artifactory.first) - #expect(record.body.utf8.count <= ArtifactoryPost.maxBodyBytes) + let record = try #require(graph.mailroom.first) + #expect(record.body.utf8.count <= MailroomPost.maxBodyBytes) #expect(record.body.hasSuffix("…")) } @@ -407,13 +407,13 @@ extension ArtifactoryTests { // The poll's write is what makes stored presence real; the wake machinery reads // the stored reading, so a live idle watcher is this, not just the hook. await store.handle(.refreshUsage) - await store.handle(.artifactoryWatch(on: true, topic: nil, from: ids[1])) + await store.handle(.mailroomWatch(on: true, topic: nil, from: ids[1])) - await store.handle(.artifactoryPost(text: "build is red", topic: nil, from: ids[0])) + await store.handle(.mailroomPost(text: "build is red", topic: nil, from: ids[0])) // A live idle watcher gets exactly one delivery, typed now — no staging line, // which is the dead-session path's record, not the live one's. - #expect(delivered.value.contains { $0.0 == ids[1] && $0.1.contains("artifactory — new post") }) + #expect(delivered.value.contains { $0.0 == ids[1] && $0.1.contains("mailroom — new post") }) #expect(!memory.value.contains { $0.0 == ids[1] && $0.1.contains("follow-up staged") }) #expect(!memory.value.contains { $0.0 == ids[1] && $0.1.contains("while you were away") }) } @@ -434,11 +434,11 @@ extension ArtifactoryTests { await store.handle( .subGraphCommand( nodeID: composite.id, - command: .artifactoryPost(text: "worker note", topic: nil, from: workerID))) + command: .mailroomPost(text: "worker note", topic: nil, from: workerID))) let graph = await store.graph #expect(errors.value.isEmpty) - #expect(graph.nodes[id: composite.id]?.subGraph?.artifactory.map(\.body) == ["worker note"]) + #expect(graph.nodes[id: composite.id]?.subGraph?.mailroom.map(\.body) == ["worker note"]) } @Test @@ -446,7 +446,7 @@ extension ArtifactoryTests { let store = await makeStore() var arriving = LoopGraph(project: ProjectRef(path: "/tmp/src", name: "src")) var node = LoopNode(title: "Visitor", loopType: .turnBased, firstInstruction: "Work") - node.lastArtifactoryRead = 5 + node.lastMailroomRead = 5 arriving.nodes.append(node) await store.handle( @@ -454,7 +454,7 @@ extension ArtifactoryTests { let graph = await store.graph let imported = try #require(graph.nodes.first { $0.title == "Visitor" }) - #expect(imported.lastArtifactoryRead == nil) + #expect(imported.lastMailroomRead == nil) } @Test @@ -463,31 +463,31 @@ extension ArtifactoryTests { let store = await makeStore(errors: errors) let ids = nodeIDs(await store.graph) - await store.handle(.artifactoryWatch(on: false, topic: nil, from: ids[0])) + await store.handle(.mailroomWatch(on: false, topic: nil, from: ids[0])) let graph = await store.graph #expect(errors.value.isEmpty) - #expect(graph.nodes[id: ids[0]]?.artifactoryWatch == nil) + #expect(graph.nodes[id: ids[0]]?.mailroomWatch == nil) } @Test func settingsRoundTripPinsTheRampBit() throws { var settings = GraphcodeSettings() - settings.artifactoryEnabled = true + settings.mailroomEnabled = true let data = try JSONEncoder().encode(settings) - #expect(try JSONDecoder().decode(GraphcodeSettings.self, from: data).artifactoryEnabled) + #expect(try JSONDecoder().decode(GraphcodeSettings.self, from: data).mailroomEnabled) } @Test func briefingAnnouncesTheBoardOnlyWhileItIsOn() { let on = SessionBriefing.text( - projectPath: "/tmp/p", settings: GraphcodeSettings(artifactoryEnabled: true)) + projectPath: "/tmp/p", settings: GraphcodeSettings(mailroomEnabled: true)) let off = SessionBriefing.text( - projectPath: "/tmp/p", settings: GraphcodeSettings(artifactoryEnabled: false)) - #expect(on?.contains("## The Artifactory — notes for whoever comes next") == true) - #expect(on?.contains("graphcode artifactory sync /tmp/p") == true) - #expect(off?.contains("## The Artifactory") == false) - // Off means byte-for-byte the pre-Artifactory briefing: no stray interpolation + projectPath: "/tmp/p", settings: GraphcodeSettings(mailroomEnabled: false)) + #expect(on?.contains("## The Mailroom — notes for whoever comes next") == true) + #expect(on?.contains("graphcode mailroom sync /tmp/p") == true) + #expect(off?.contains("## The Mailroom") == false) + // Off means byte-for-byte the pre-Mailroom briefing: no stray interpolation // line where the section would have gone. #expect(off?.contains("one-off.\n\n## Remembering across passes") == true) } @@ -495,7 +495,7 @@ extension ArtifactoryTests { @Test func wakeDigestRemindsAboutTheBoardOnlyWhileItIsOn() throws { let baseURL = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent("artifactory-tests-\(UUID().uuidString)") + .appendingPathComponent("mailroom-tests-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: baseURL) } let projectPath = "/tmp/digest" let nodeID = UUID() @@ -503,15 +503,15 @@ extension ArtifactoryTests { "something happened", projectPath: projectPath, nodeID: nodeID, baseURL: baseURL) let on = NodeMemory.writeWakeDigest( - projectPath: projectPath, nodeID: nodeID, artifactoryEnabled: true, baseURL: baseURL) + projectPath: projectPath, nodeID: nodeID, mailroomEnabled: true, baseURL: baseURL) #expect(on != nil) - #expect(try String(contentsOf: try #require(on), encoding: .utf8).contains("Artifactory")) + #expect(try String(contentsOf: try #require(on), encoding: .utf8).contains("Mailroom")) let off = NodeMemory.writeWakeDigest( - projectPath: projectPath, nodeID: nodeID, artifactoryEnabled: false, baseURL: baseURL) + projectPath: projectPath, nodeID: nodeID, mailroomEnabled: false, baseURL: baseURL) #expect( !(try String(contentsOf: try #require(off), encoding: .utf8).contains("Mailboard"))) #expect( - !(try String(contentsOf: try #require(off), encoding: .utf8).contains("Artifactory"))) + !(try String(contentsOf: try #require(off), encoding: .utf8).contains("Mailroom"))) } } diff --git a/graphcode/Tests/RemoteCLIShimTests.swift b/graphcode/Tests/RemoteCLIShimTests.swift index 0ab29bbe..78b99bc9 100644 --- a/graphcode/Tests/RemoteCLIShimTests.swift +++ b/graphcode/Tests/RemoteCLIShimTests.swift @@ -1,4 +1,4 @@ -import ArtifactoryKit +import MailroomKit import Foundation import Testing @@ -230,17 +230,17 @@ extension RemoteCLIShimTests { private static func board() -> (LoopGraph, LoopNode) { var graph = LoopGraph(project: ProjectRef(path: project, name: "widget")) var reader = LoopNode(title: "Reader", loopType: .goalBased) - reader.lastArtifactoryRead = 1 + reader.lastMailroomRead = 1 graph.nodes.append(reader) - graph.artifactory = [ - ArtifactoryPost( + graph.mailroom = [ + MailroomPost( id: 1, at: Date(timeIntervalSince1970: 1_756_000_000), authorID: nil, author: "a human", topic: nil, body: "already read"), - ArtifactoryPost( + MailroomPost( id: 2, at: Date(timeIntervalSince1970: 1_756_003_600), authorID: UUID(), author: "BuildWatch", topic: "build", body: String(repeating: "the gate is red ", count: 12)), - ArtifactoryPost( + MailroomPost( id: 3, at: Date(timeIntervalSince1970: 1_756_007_200), authorID: nil, author: "a human", topic: nil, // A path and a URL on purpose: Swift's JSONEncoder escapes `/` as `\/` and @@ -263,38 +263,38 @@ extension RemoteCLIShimTests { let session = ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"] let cases: [(arguments: [String], environment: [String: String], expected: String)] = [ - (["artifactory", "list", Self.project], [:], GraphcodeCommand.renderArtifactory(graph)), + (["mailroom", "list", Self.project], [:], GraphcodeCommand.renderMailroom(graph)), ( - ["artifactory", "list", Self.project, "--search", "RED"], [:], - GraphcodeCommand.renderArtifactory(graph, search: "RED") + ["mailroom", "list", Self.project, "--search", "RED"], [:], + GraphcodeCommand.renderMailroom(graph, search: "RED") ), ( - ["artifactory", "list", Self.project, "--search", "nothing-matches"], [:], - GraphcodeCommand.renderArtifactory(graph, search: "nothing-matches") + ["mailroom", "list", Self.project, "--search", "nothing-matches"], [:], + GraphcodeCommand.renderMailroom(graph, search: "nothing-matches") ), ( - ["artifactory", "sync", Self.project], session, - GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id, autoTriage: true) + ["mailroom", "sync", Self.project], session, + GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id, autoTriage: true) ), ( - ["artifactory", "sync", Self.project, "--headlines"], session, - GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id, headlines: true) + ["mailroom", "sync", Self.project, "--headlines"], session, + GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id, headlines: true) ), ( - ["artifactory", "sync", Self.project, "--full"], session, - GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id) + ["mailroom", "sync", Self.project, "--full"], session, + GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id) ), ( - ["artifactory", "read", Self.project, "2"], [:], - GraphcodeCommand.render(graph.artifactory[1]) + ["mailroom", "read", Self.project, "2"], [:], + GraphcodeCommand.render(graph.mailroom[1]) ), ( - ["artifactory", "list", Self.project, "--json"], [:], - GraphcodeCommand.renderArtifactoryJSON(graph) + ["mailroom", "list", Self.project, "--json"], [:], + GraphcodeCommand.renderMailroomJSON(graph) ), ( - ["artifactory", "sync", Self.project, "--json"], session, - GraphcodeCommand.renderArtifactoryJSON(graph, unreadFor: reader.id) + ["mailroom", "sync", Self.project, "--json"], session, + GraphcodeCommand.renderMailroomJSON(graph, unreadFor: reader.id) ), ] @@ -313,23 +313,23 @@ extension RemoteCLIShimTests { func anEmptyBoardAndACaughtUpReaderRenderByteEqualToo() throws { var graph = LoopGraph(project: ProjectRef(path: Self.project, name: "widget")) var reader = LoopNode(title: "Reader", loopType: .goalBased) - reader.lastArtifactoryRead = 3 + reader.lastMailroomRead = 3 graph.nodes.append(reader) - let empty = try runShim(["artifactory", "list", Self.project], graph: graph) - #expect(empty.stdout == GraphcodeCommand.renderArtifactory(graph) + "\n") + let empty = try runShim(["mailroom", "list", Self.project], graph: graph) + #expect(empty.stdout == GraphcodeCommand.renderMailroom(graph) + "\n") - graph.artifactory = [ - ArtifactoryPost( + graph.mailroom = [ + MailroomPost( id: 3, at: Date(timeIntervalSince1970: 1_756_000_000), authorID: nil, author: "a human", topic: nil, body: "caught up") ] let synced = try runShim( - ["artifactory", "sync", Self.project], + ["mailroom", "sync", Self.project], environment: ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"], graph: graph) #expect( synced.stdout - == GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id, autoTriage: true) + "\n") + == GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id, autoTriage: true) + "\n") #expect(synced.stdout == "no unread posts\n") } @@ -339,8 +339,8 @@ extension RemoteCLIShimTests { @Test func theStampConvertsFromFoundationsReferenceDate() throws { let (graph, _) = Self.board() - let stamp = ArtifactoryPost.stampFormat.string(from: graph.artifactory[0].at) - let run = try runShim(["artifactory", "read", Self.project, "1"], graph: graph) + let stamp = MailroomPost.stampFormat.string(from: graph.mailroom[0].at) + let run = try runShim(["mailroom", "read", Self.project, "1"], graph: graph) #expect(run.stdout == "#1 from a human at \(stamp) — already read\n") // The stamp cannot carry the whole guard on its own. `MMM d, HH:mm` has no year, @@ -348,12 +348,12 @@ extension RemoteCLIShimTests { // years across this span's eight leap days — so dropping it entirely renders the // *identical* stamp, "Aug 23, 18:46" either way. The year rides `--json`'s // ISO-8601 instead, where the same mistake cannot hide. - let json = try runShim(["artifactory", "list", Self.project, "--json"], graph: graph) + let json = try runShim(["mailroom", "list", Self.project, "--json"], graph: graph) #expect(json.stdout.contains("\"at\":\"2025-08-24T01:46:40Z\"")) #expect(!json.stdout.contains("\"at\":\"1994-")) } - /// The triage boundary, both halves of it: `Artifactory.needsTriage` is more than 12 + /// The triage boundary, both halves of it: `Mailroom.needsTriage` is more than 12 /// posts *or* more than 4096 bytes of body, and a sync that trips either one prints /// headlines and says so. Off-by-one here silently truncates a board a loop was told /// it had read in full. @@ -364,8 +364,8 @@ extension RemoteCLIShimTests { let reader = LoopNode(title: "Reader", loopType: .goalBased) graph.nodes.append(reader) let each = bodyBytes / posts - graph.artifactory = (1...posts).map { index in - ArtifactoryPost( + graph.mailroom = (1...posts).map { index in + MailroomPost( id: index, at: Date(timeIntervalSince1970: 1_756_000_000 + Double(index)), authorID: nil, author: "a human", topic: nil, body: String( @@ -373,11 +373,11 @@ extension RemoteCLIShimTests { ) } let run = try runShim( - ["artifactory", "sync", Self.project], + ["mailroom", "sync", Self.project], environment: ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"], graph: graph) return ( run.stdout, - GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id, autoTriage: true) + "\n" + GraphcodeCommand.renderMailroom(graph, unreadFor: reader.id, autoTriage: true) + "\n" ) } @@ -411,15 +411,15 @@ extension RemoteCLIShimTests { ["status", Self.project], environment: ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"], graph: graph) let readerLine = try #require( - GraphcodeCommand.renderArtifactoryStatusLine(graph, readerID: reader.id)) - #expect(readerLine == "artifactory: 3 posts, 2 unread for you") + GraphcodeCommand.renderMailroomStatusLine(graph, readerID: reader.id)) + #expect(readerLine == "mailroom: 3 posts, 2 unread for you") #expect(asReader.stdout.hasSuffix(" " + readerLine + "\n")) // A human shell, and a loop this graph has never heard of, both get the plain // count — the daemon would refuse a cursor for either. let asHuman = try runShim(["status", Self.project], graph: graph) - let plain = try #require(GraphcodeCommand.renderArtifactoryStatusLine(graph)) - #expect(plain == "artifactory: 3 posts") + let plain = try #require(GraphcodeCommand.renderMailroomStatusLine(graph)) + #expect(plain == "mailroom: 3 posts") #expect(asHuman.stdout.hasSuffix(" " + plain + "\n")) let asStranger = try runShim( @@ -431,7 +431,7 @@ extension RemoteCLIShimTests { let untouched = try runShim( ["status", Self.project], graph: LoopGraph(project: ProjectRef(path: Self.project, name: "widget"))) - #expect(!untouched.stdout.contains("artifactory")) + #expect(!untouched.stdout.contains("mailroom")) } @Test @@ -440,7 +440,7 @@ extension RemoteCLIShimTests { let session = ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"] let posted = try runShim( - ["artifactory", "post", Self.project, "--topic", "claims", "issue", "#12", "is", "mine"], + ["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") @@ -448,43 +448,43 @@ extension RemoteCLIShimTests { posted.commands.dropFirst().first == .graphCommand( projectPath: Self.project, - command: .artifactoryPost(text: "issue #12 is mine", topic: "claims", from: reader.id))) + command: .mailroomPost(text: "issue #12 is mine", topic: "claims", from: reader.id))) // A human's post is unattributed, exactly as `node send` from a shell is. let byHuman = try runShim( - ["artifactory", "post", Self.project, "the", "board", "is", "for", "everyone"], graph: graph) + ["mailroom", "post", Self.project, "the", "board", "is", "for", "everyone"], graph: graph) #expect( byHuman.commands.dropFirst().first == .graphCommand( projectPath: Self.project, - command: .artifactoryPost( + command: .mailroomPost( text: "the board is for everyone", topic: nil, from: nil))) let synced = try runShim( - ["artifactory", "sync", Self.project, "--mark"], environment: session, graph: graph) + ["mailroom", "sync", Self.project, "--mark"], environment: session, graph: graph) #expect(synced.stdout == "marked read up to #3\n") #expect( synced.commands.dropFirst().first - == .graphCommand(projectPath: Self.project, command: .artifactorySync(from: reader.id))) + == .graphCommand(projectPath: Self.project, command: .mailroomSync(from: reader.id))) let watching = try runShim( - ["artifactory", "watch", Self.project, "--topic", "build"], environment: session, + ["mailroom", "watch", Self.project, "--topic", "build"], environment: session, graph: graph) #expect(watching.stdout.hasPrefix("watching 'build' —")) #expect( watching.commands.dropFirst().first == .graphCommand( projectPath: Self.project, - command: .artifactoryWatch(on: true, topic: "build", from: reader.id))) + command: .mailroomWatch(on: true, topic: "build", from: reader.id))) let unwatching = try runShim( - ["artifactory", "watch", Self.project, "--off"], environment: session, graph: graph) + ["mailroom", "watch", Self.project, "--off"], environment: session, graph: graph) #expect(unwatching.stdout == "stopped watching\n") #expect( unwatching.commands.dropFirst().first == .graphCommand( projectPath: Self.project, - command: .artifactoryWatch(on: false, topic: nil, from: reader.id))) + command: .mailroomWatch(on: false, topic: nil, from: reader.id))) } /// Swift measures a headline in extended grapheme clusters and Python in code points, @@ -509,8 +509,8 @@ extension RemoteCLIShimTests { for (index, body) in bodies.enumerated() { var graph = LoopGraph(project: ProjectRef(path: Self.project, name: "widget")) - graph.artifactory = [ - ArtifactoryPost( + graph.mailroom = [ + MailroomPost( id: 1, at: Date(timeIntervalSince1970: 1_756_000_000), authorID: nil, author: "a human", topic: nil, body: body) ] @@ -518,9 +518,9 @@ extension RemoteCLIShimTests { graph.nodes.append(reader) let run = try runShim( - ["artifactory", "sync", Self.project, "--headlines"], + ["mailroom", "sync", Self.project, "--headlines"], environment: ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"], graph: graph) - let expected = GraphcodeCommand.renderArtifactory( + let expected = GraphcodeCommand.renderMailroom( graph, unreadFor: reader.id, headlines: true) #expect(run.stdout == expected + "\n", "body \(index) cut differently") } @@ -533,11 +533,11 @@ extension RemoteCLIShimTests { @Test func searchMatchesCanonicallyEquivalentTextAsSwiftDoes() throws { var graph = LoopGraph(project: ProjectRef(path: Self.project, name: "widget")) - graph.artifactory = [ - ArtifactoryPost( + graph.mailroom = [ + MailroomPost( id: 1, at: Date(timeIntervalSince1970: 1_756_000_000), authorID: nil, author: "a human", topic: nil, body: "shipped the e\u{0301}clair build"), - ArtifactoryPost( + MailroomPost( id: 2, at: Date(timeIntervalSince1970: 1_756_003_600), authorID: nil, author: "Ame\u{0301}lie", topic: "cafe\u{0301}", body: "unrelated"), ] @@ -545,20 +545,20 @@ extension RemoteCLIShimTests { // Precomposed needles against decomposed body, author and topic — and the reverse. for needle in ["éclair", "e\u{0301}clair", "Amélie", "café", "ÉCLAIR"] { let run = try runShim( - ["artifactory", "list", Self.project, "--search", needle], graph: graph) - let expected = GraphcodeCommand.renderArtifactory(graph, search: needle) + ["mailroom", "list", Self.project, "--search", needle], graph: graph) + let expected = GraphcodeCommand.renderMailroom(graph, search: needle) #expect(run.stdout == expected + "\n", "search '\(needle)' diverged") #expect(!expected.hasPrefix("no posts match"), "fixture no longer exercises a match") } } - /// The parser's remaining shape, matched to `parseArtifactory`: a `--flag` is never a + /// The parser's remaining shape, matched to `parseMailroom`: a `--flag` is never a /// project path, and a topic is an Optional rather than a truthiness test — the empty /// topic the daemon refuses today still has to render the way Swift renders it, or /// this is a second rule the renderer would need re-auditing against if that moved. @Test func theParserAndTheEmptyTopicMatchTheSwiftCLIsShape() throws { - let missingPath = try runShim(["artifactory", "list", "--json"]) + let missingPath = try runShim(["mailroom", "list", "--json"]) #expect(missingPath.status == 1) #expect(missingPath.stderr.contains("missing project-path")) // Refused before the dial, so the daemon is never asked to open a project called @@ -566,17 +566,17 @@ extension RemoteCLIShimTests { #expect(missingPath.commands.isEmpty) var graph = LoopGraph(project: ProjectRef(path: Self.project, name: "widget")) - graph.artifactory = [ - ArtifactoryPost( + graph.mailroom = [ + MailroomPost( id: 5, at: Date(timeIntervalSince1970: 1_756_000_000), authorID: nil, author: "a human", topic: "", body: "a topic that is present but empty") ] - let run = try runShim(["artifactory", "read", Self.project, "5"], graph: graph) - #expect(run.stdout == GraphcodeCommand.render(graph.artifactory[0]) + "\n") + let run = try runShim(["mailroom", "read", Self.project, "5"], graph: graph) + #expect(run.stdout == GraphcodeCommand.render(graph.mailroom[0]) + "\n") #expect(run.stdout.contains("#5 () from a human")) let posted = try runShim( - ["artifactory", "post", Self.project, "anything"], graph: graph) + ["mailroom", "post", Self.project, "anything"], graph: graph) #expect(posted.stdout == GraphcodeCommand.renderPosted(graph) + "\n") #expect(posted.stdout == "posted #5 ()\n") } @@ -587,7 +587,7 @@ extension RemoteCLIShimTests { func readAndListSendNoCommandPastTheOpen() throws { let (graph, _) = Self.board() for arguments in [ - ["artifactory", "read", Self.project, "3"], ["artifactory", "list", Self.project], + ["mailroom", "read", Self.project, "3"], ["mailroom", "list", Self.project], ] { let run = try runShim(arguments, graph: graph) #expect(run.status == 0) @@ -596,18 +596,18 @@ extension RemoteCLIShimTests { } /// The cursor verbs refuse a human shell up front, in the Swift CLI's own wording, - /// rather than after a round trip — and `artifactory` no longer falls through to the + /// rather than after a round trip — and `mailroom` no longer falls through to the /// "Mac-only" refusal that made every verb on this list exit 1. @Test - func theCursorVerbsNeedALoopIdentityAndArtifactoryIsNoLongerMacOnly() throws { - let sync = try runShim(["artifactory", "sync", Self.project]) + func theCursorVerbsNeedALoopIdentityAndMailroomIsNoLongerMacOnly() throws { + let sync = try runShim(["mailroom", "sync", Self.project]) #expect(sync.status == 1) - #expect(sync.stderr.contains("artifactory sync needs a loop identity")) - #expect(sync.stderr.contains("graphcode artifactory list")) + #expect(sync.stderr.contains("mailroom sync needs a loop identity")) + #expect(sync.stderr.contains("graphcode mailroom list")) - let watch = try runShim(["artifactory", "watch", Self.project]) + let watch = try runShim(["mailroom", "watch", Self.project]) #expect(watch.status == 1) - #expect(watch.stderr.contains("artifactory watch needs a loop identity")) + #expect(watch.stderr.contains("mailroom watch needs a loop identity")) #expect(watch.stderr.contains("the mail is delivered to the loop that watches")) // Neither reached the daemon, so nothing was applied and nothing needs undoing. @@ -615,15 +615,15 @@ extension RemoteCLIShimTests { #expect(watch.commands.isEmpty) for verb in ["post", "sync", "read", "list", "watch"] { - let run = try runShim(["artifactory", verb]) - #expect(!run.stderr.contains("Mac-only"), "artifactory \(verb) still refused as Mac-only") + let run = try runShim(["mailroom", verb]) + #expect(!run.stderr.contains("Mac-only"), "mailroom \(verb) still refused as Mac-only") } // A subcommand that genuinely does not exist says so as the Swift CLI does. - let bogus = try runShim(["artifactory", "resolve", Self.project]) + let bogus = try runShim(["mailroom", "resolve", Self.project]) #expect(bogus.status == 1) - #expect(bogus.stderr.contains("unknown command: artifactory resolve")) + #expect(bogus.stderr.contains("unknown command: mailroom resolve")) // And a mistyped flag is refused rather than silently ignored. - let mistyped = try runShim(["artifactory", "list", Self.project, "--serach", "red"]) + let mistyped = try runShim(["mailroom", "list", Self.project, "--serach", "red"]) #expect(mistyped.status == 1) #expect(mistyped.stderr.contains("unknown option: --serach")) } @@ -631,12 +631,12 @@ extension RemoteCLIShimTests { @Test func readNamesTheIdsThatExistWhenThePostIsGone() throws { let (graph, _) = Self.board() - let missing = try runShim(["artifactory", "read", Self.project, "99"], graph: graph) + let missing = try runShim(["mailroom", "read", Self.project, "99"], graph: graph) #expect(missing.status == 1) #expect(missing.stderr.contains("no post #99 on this board")) - #expect(missing.stderr.contains("graphcode artifactory list")) + #expect(missing.stderr.contains("graphcode mailroom list")) - let negative = try runShim(["artifactory", "read", Self.project, "-7"], graph: graph) + let negative = try runShim(["mailroom", "read", Self.project, "-7"], graph: graph) #expect(negative.status == 1) #expect(negative.stderr.contains("invalid value for post-id: -7")) #expect(negative.commands.isEmpty) @@ -644,12 +644,12 @@ extension RemoteCLIShimTests { @Test func theHelpTextTeachesEveryVerbTheBriefingDoes() throws { - let help = try runShim(["artifactory", "--help"]) + let help = try runShim(["mailroom", "--help"]) #expect(help.status == 0) for verb in ["post", "sync", "read", "list", "watch"] { - #expect(help.stdout.contains("graphcode artifactory \(verb) ")) + #expect(help.stdout.contains("graphcode mailroom \(verb) ")) } // The shim's own honesty rule: nothing it implements may sit on the Mac-only list. - #expect(!help.stdout.contains("(update, pilot, arm, edge, usage, artifactory)")) + #expect(!help.stdout.contains("(update, pilot, arm, edge, usage, mailroom)")) } } diff --git a/graphcode/Tests/SettingsArtifactoryTests.swift b/graphcode/Tests/SettingsMailroomTests.swift similarity index 81% rename from graphcode/Tests/SettingsArtifactoryTests.swift rename to graphcode/Tests/SettingsMailroomTests.swift index 64a65669..352a2982 100644 --- a/graphcode/Tests/SettingsArtifactoryTests.swift +++ b/graphcode/Tests/SettingsMailroomTests.swift @@ -3,16 +3,16 @@ import Testing @testable import graphcode -/// The Artifactory's boot decision in the app: the ramp answers for an install that has +/// The Mailroom's boot decision in the app: the ramp answers for an install that has /// never chosen, a recorded choice outranks it from then on, and the resolved bit /// reaches `settings.json` only when it differs — the daemon enforces the setting out /// of the file and cannot see ramps or `UserDefaults`. @Suite -struct SettingsArtifactoryTests { +struct SettingsMailroomTests { private func resolution( loaded: Bool, choice: Bool?, rampedOn: Bool - ) -> SettingsModel.ArtifactoryResolution { - SettingsModel.resolvesArtifactory( + ) -> SettingsModel.MailroomResolution { + SettingsModel.resolvesMailroom( loaded: loaded, explicitChoice: choice, rampedOn: rampedOn) } @@ -22,13 +22,13 @@ struct SettingsArtifactoryTests { // daemon — which never sees the ramp — keeps the board off. #expect( resolution(loaded: false, choice: nil, rampedOn: true) - == SettingsModel.ArtifactoryResolution( + == SettingsModel.MailroomResolution( enabled: true, fileNeedsWrite: true)) // Should the ramp ever be pulled, an install that never chose boots off, and a // file that already agrees is left alone. #expect( resolution(loaded: false, choice: nil, rampedOn: false) - == SettingsModel.ArtifactoryResolution( + == SettingsModel.MailroomResolution( enabled: false, fileNeedsWrite: false)) } @@ -37,12 +37,12 @@ struct SettingsArtifactoryTests { // An explicit on survives the ramp being pulled back. #expect( resolution(loaded: true, choice: true, rampedOn: false) - == SettingsModel.ArtifactoryResolution( + == SettingsModel.MailroomResolution( enabled: true, fileNeedsWrite: false)) // An explicit off survives the ramp turning everyone on. #expect( resolution(loaded: false, choice: false, rampedOn: true) - == SettingsModel.ArtifactoryResolution( + == SettingsModel.MailroomResolution( enabled: false, fileNeedsWrite: false)) } @@ -52,7 +52,7 @@ struct SettingsArtifactoryTests { // bytes would be churn. #expect( resolution(loaded: true, choice: nil, rampedOn: true) - == SettingsModel.ArtifactoryResolution( + == SettingsModel.MailroomResolution( enabled: true, fileNeedsWrite: false)) } @@ -63,7 +63,7 @@ struct SettingsArtifactoryTests { // switch stays: it is a setting, and a person can turn the board back on. #expect( resolution(loaded: true, choice: nil, rampedOn: false) - == SettingsModel.ArtifactoryResolution( + == SettingsModel.MailroomResolution( enabled: false, fileNeedsWrite: true)) } } diff --git a/graphcode/Tests/StarterTemplateTests.swift b/graphcode/Tests/StarterTemplateTests.swift index 8a654b60..886aa39d 100644 --- a/graphcode/Tests/StarterTemplateTests.swift +++ b/graphcode/Tests/StarterTemplateTests.swift @@ -153,7 +153,7 @@ struct StarterTemplateTests { } } - /// The brief at the top is about the task; the Artifactory appears in it as the + /// The brief at the top is about the task; the Mailroom appears in it as the /// team's inbox, and every command it names is one the CLI actually has. @Test func theTeamLeadingStarterNamesRealCommands() throws { @@ -166,8 +166,8 @@ struct StarterTemplateTests { #expect(lead.body.hasPrefix("Goal: {goal}\n")) // The method in words, not a CLI transcript — the loop's own instructions carry // the flags. What the brief has to say is which loop type for which piece, and - // that the Artifactory is the inbox. - for phrase in ["goal loop", "timed loop", "Artifactory", "inbox", "topic", "closing note"] { + // that the Mailroom is the inbox. + for phrase in ["goal loop", "timed loop", "Mailroom", "inbox", "topic", "closing note"] { #expect(lead.body.contains(phrase), "missing \(phrase)") } } From a74281e8158e2a85d11a26d10e1e558f354d012c Mon Sep 17 00:00:00 2001 From: scgopi Date: Fri, 4 Sep 2026 21:10:43 -0700 Subject: [PATCH 2/6] Mailroom vocabulary: notices and letters, and a letter budget that fits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mail framing earns a lexicon the old name could not: a post addressed to nobody is a notice, the room's copy of a delivered message is a letter. `Kind.note`/`.record` rename to match, decoding the old spellings — an unrecognised kind reads as a notice rather than throwing, because ProjectPersistence turns any decode failure into "no saved graph" and a strict reading would trade one unknown post for the whole board. maxRecords 50 -> maxLetters 200. Fifty was priced as receipts for traffic that mattered elsewhere; with correspondence half of what the room is for, a ten-way fanout spent the budget in one pass and evicted the history the next loop joins to read. Level with the notices, still pruned apart, so neither kind of traffic can crowd out the other. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AtVtkzZNE5fLPeHVqM7A3K --- .../Sources/CLI/GraphcodeCommand.swift | 2 +- GraphcodeKit/Sources/Domain/LoopGraph.swift | 2 +- GraphcodeKit/Sources/GraphStore.swift | 4 +- .../Sources/Sessions/RemoteGraphAccess.swift | 2 +- MailroomKit/Sources/Mailroom.swift | 62 ++++++++++++------- .../LoopWorkspace/LoopWorkspaceFeature.swift | 2 +- .../LoopWorkspace/MailroomSection.swift | 26 ++++---- graphcode/Tests/MailroomBudgetTests.swift | 24 +++---- .../Tests/MailroomSinceYouLookedTests.swift | 8 +-- graphcode/Tests/MailroomTests.swift | 6 +- graphcode/Tests/RemoteCLIShimTests.swift | 2 +- 11 files changed, 78 insertions(+), 62 deletions(-) diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index e274f32c..22f7fb98 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -229,7 +229,7 @@ public enum GraphcodeCommand: Equatable, Sendable { MAILROOM The shared, unaddressed board: `node send` reaches one peer you already know; - an Mailroom post is a note for whoever comes next, discoverable by loops that + a Mailroom post is a note for whoever comes next, discoverable by loops that did not exist when it was written. Run from inside a loop, posts are attributed to that loop (`ZMX_SESSION`, the same mechanism as `node send`); from a human's shell they read as from "a human". `sync` and `watch` need that loop identity — diff --git a/GraphcodeKit/Sources/Domain/LoopGraph.swift b/GraphcodeKit/Sources/Domain/LoopGraph.swift index 161c525c..d1b30140 100644 --- a/GraphcodeKit/Sources/Domain/LoopGraph.swift +++ b/GraphcodeKit/Sources/Domain/LoopGraph.swift @@ -22,7 +22,7 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { 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 - /// (`Mailroom.maxNotes`, `Mailroom.maxRecords`). Kept on the graph rather than in a + /// (`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 diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index 3fa7037a..077789fb 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -1665,7 +1665,7 @@ public actor GraphStore { /// memory to), so mirroring must not ring the watchers, or a busy graph would have /// every direct message waking every listener on top of its real delivery. /// Gated like every board write; body carries the target so a reader can tell a - /// note to the room from a note to a peer. Written as `.record`, which is what keeps + /// note to the room from a note to a peer. Written as `.letter`, which is what keeps /// a talkative graph inside its own budget instead of evicting the notes. private func recordMailroomCommunication( from senderID: UUID?, to target: LoopNode, text: String, topic: String @@ -1681,7 +1681,7 @@ public actor GraphStore { } let post = MailroomPost( id: Mailroom.nextID(after: graph.mailroom), at: Date(), authorID: senderID, - author: sender, topic: topic, body: body, kind: .record) + author: sender, topic: topic, body: body, kind: .letter) graph.mailroom = Mailroom.pruned(graph.mailroom + [post]) } diff --git a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift index f88972de..7ffb9654 100644 --- a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift +++ b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift @@ -507,7 +507,7 @@ public enum RemoteGraphAccess { def encoded_post(post): - # What JSONEncoder makes of an MailroomPost: absent rather than null for the + # What JSONEncoder makes of a MailroomPost: absent rather than null for the # optionals, ISO-8601 for the date, and `kind` defaulted the way the hand-written # decoder defaults it for boards saved before records had their own quota. encoded = {"id": post.get("id"), "at": iso8601(post.get("at")), diff --git a/MailroomKit/Sources/Mailroom.swift b/MailroomKit/Sources/Mailroom.swift index 324a2c39..e848fa5b 100644 --- a/MailroomKit/Sources/Mailroom.swift +++ b/MailroomKit/Sources/Mailroom.swift @@ -1,6 +1,6 @@ import Foundation -/// One post on an Mailroom — the shared, unaddressed message board a graph of loops +/// One post on a Mailroom — the shared, unaddressed message board a graph of loops /// writes to and reads without wiring anything: `node send` and edges are addressed /// (a sender must already know a target's id, and the daemon routes to that one peer), /// while the Mailroom is the ambient counterpart. A loop drops a note for *whoever @@ -22,10 +22,22 @@ public struct MailroomPost: Codable, Equatable, Identifiable, Sendable { /// trying — evicted every note on it. Separate budgets are the fix: chatter can fill /// its own quota to the brim and never touch a note. public enum Kind: String, Codable, Sendable { - /// Somebody posted this on purpose (`graphcode mailroom post`). - case note - /// The board's mirror of a delivered direct message or handoff. - case record + /// Somebody posted this on purpose — addressed to nobody, which is the whole of + /// what separates it from a letter. + case notice + /// The room's copy of a direct message or handoff that was delivered elsewhere. + case letter + + /// Boards written before the mail vocabulary spell these `note` and `record`. An + /// unrecognised kind reads as a notice rather than throwing: `ProjectPersistence` + /// turns any decode failure into "no saved graph", so a strict reading here would + /// trade one unknown post for every post on the board. + public init(from decoder: Decoder) throws { + switch try decoder.singleValueContainer().decode(String.self) { + case "letter", "record": self = .letter + default: self = .notice + } + } } /// Position in the board's sequence, 1-based. The unread cursor is this number, so @@ -68,7 +80,7 @@ public struct MailroomPost: Codable, Equatable, Identifiable, Sendable { public init( id: Int, at: Date, authorID: UUID?, author: String, topic: String?, body: String, - kind: Kind = .note + kind: Kind = .notice ) { self.id = id self.at = at @@ -94,7 +106,7 @@ public struct MailroomPost: Codable, Equatable, Identifiable, Sendable { author = try container.decode(String.self, forKey: .author) topic = try container.decodeIfPresent(String.self, forKey: .topic) body = try container.decode(String.self, forKey: .body) - kind = try container.decodeIfPresent(Kind.self, forKey: .kind) ?? .note + kind = try container.decodeIfPresent(Kind.self, forKey: .kind) ?? .notice } /// The same post with its author's handle gone — what deleting a loop leaves behind. @@ -129,17 +141,21 @@ public struct MailroomWatch: Codable, Equatable, Sendable { /// The board's own rules — the arithmetic every surface shares rather than /// re-derives, so the CLI's unread count and the daemon's cursor can never disagree. public enum Mailroom { - /// How many *notes* a board keeps. The oldest fall off first: an Mailroom is a - /// mailbox for the work that is happening, not an archive — a loop's durable - /// findings belong in its memory log, and the board's job is carrying them to - /// loops that cannot read that log. - public static let maxNotes = 200 - - /// How many mirrored records a board keeps, pruned entirely separately from the - /// notes. Smaller because a record is a receipt for something already delivered: - /// enough that a loop joining mid-flight can see what was recently said, not so - /// many that the graph's chatter becomes the board. - public static let maxRecords = 50 + /// How many *notices* a room keeps. The oldest fall off first: a Mailroom carries + /// the work that is happening, it is not an archive — a loop's durable findings + /// belong in its memory log, and the room's job is carrying them to loops that + /// cannot read that log. + public static let maxNotices = 200 + + /// How many *letters* a room keeps, pruned entirely separately from the notices. + /// + /// Was 50, on the reading that a letter is a mere receipt for something already + /// delivered. That undersold it: correspondence is half of what the room is for, + /// and a ten-way fanout spends 50 slots in a single pass — so the loop that joins + /// afterwards finds the graph's history already evicted, which is the failure the + /// separate budgets existed to prevent. Level with the notices, and pruned apart + /// from them, so neither kind of traffic can crowd the other out. + public static let maxLetters = 200 /// The id the next post gets. Maximum-plus-one, never count-plus-one: pruning /// removes the oldest posts, and reusing their ids would make unread cursors @@ -154,7 +170,7 @@ public enum Mailroom { /// The pair `sync --headlines` / `read ` already existed, but choosing between /// them is a decision a loop cannot make: it learns how much mail it has by reading /// it, and a loop created after a busy week inherits the whole board on its first - /// sync — measured at 200 notes, that is ~180 KB, or something like 45,000 tokens + /// sync — measured at 200 notices, that is ~180 KB, or something like 45,000 tokens /// spent before the loop has done anything. So the verb decides, and says which /// way it went; `--full` overrides for a caller that really does want every body. public static let triageAfterPosts = 12 @@ -177,11 +193,11 @@ public enum Mailroom { /// A board pruned to both budgets, oldest of each kind gone first and the survivors /// back in one sequence. Applied by the store on every write so no caller can forget. public static func pruned(_ posts: [MailroomPost]) -> [MailroomPost] { - let notes = posts.filter { $0.kind == .note } - let records = posts.filter { $0.kind == .record } - guard notes.count > maxNotes || records.count > maxRecords else { return posts } + let notices = posts.filter { $0.kind == .notice } + let letters = posts.filter { $0.kind == .letter } + guard notices.count > maxNotices || letters.count > maxLetters else { return posts } let kept = Set( - (notes.suffix(maxNotes) + records.suffix(maxRecords)).map(\.id)) + (notices.suffix(maxNotices) + letters.suffix(maxLetters)).map(\.id)) return posts.filter { kept.contains($0.id) } } } diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift index 14db342a..197da7eb 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift @@ -326,7 +326,7 @@ struct LoopWorkspaceFeature { // folded section showed no posts, and marking them seen would clear a badge // the human never had a chance to read. if state.isRailVisible, !state.isMailroomFolded, - let newest = MailroomPresentation.notes(in: state.graph).last?.id + let newest = MailroomPresentation.notices(in: state.graph).last?.id { state.seenMailroomPostID = newest LoopWorkspaceRail.saveSeenMailroomPost(newest, forProjectPath: state.projectPath) diff --git a/graphcode/Sources/Features/LoopWorkspace/MailroomSection.swift b/graphcode/Sources/Features/LoopWorkspace/MailroomSection.swift index 5200993e..4d917ebb 100644 --- a/graphcode/Sources/Features/LoopWorkspace/MailroomSection.swift +++ b/graphcode/Sources/Features/LoopWorkspace/MailroomSection.swift @@ -16,23 +16,23 @@ enum MailroomPresentation { /// The posts somebody wrote on purpose, newest last — the direction the summary and /// the terminal beside it already run. - static func notes(in graph: LoopGraph) -> [MailroomPost] { - graph.mailroom.filter { $0.kind == .note } + static func notices(in graph: LoopGraph) -> [MailroomPost] { + graph.mailroom.filter { $0.kind == .notice } } - /// The mirrored direct messages and handoffs. Kept apart from the notes because they + /// The mirrored direct messages and handoffs. Kept apart from the notices because they /// are receipts for deliveries that already happened, not something written to be /// read here. - static func records(in graph: LoopGraph) -> [MailroomPost] { - graph.mailroom.filter { $0.kind == .record } + static func letters(in graph: LoopGraph) -> [MailroomPost] { + graph.mailroom.filter { $0.kind == .letter } } - /// How many notes have landed since the human last looked — `seenPostID` is - /// `LoopWorkspaceFeature.seenMailroomPostID`, not the loop's sync cursor. Records - /// are excluded: they are folded away by default, and a badge counting mail nobody is + /// How many notices have landed since the human last looked — `seenPostID` is + /// `LoopWorkspaceFeature.seenMailroomPostID`, not the loop's inbox cursor. + /// Letters are excluded: they are folded away by default, and a badge counting mail nobody is /// being shown is a badge that cannot be cleared. - static func unreadNoteCount(graph: LoopGraph, seenPostID: Int?) -> Int { - Mailroom.unread(in: notes(in: graph), since: seenPostID).count + static func unreadNoticeCount(graph: LoopGraph, seenPostID: Int?) -> Int { + Mailroom.unread(in: notices(in: graph), since: seenPostID).count } } @@ -74,10 +74,10 @@ struct MailroomSection: View { @State private var draftTopic = "" @FocusState private var draftFocused: Bool - private var notes: [MailroomPost] { MailroomPresentation.notes(in: graph) } - private var records: [MailroomPost] { MailroomPresentation.records(in: graph) } + private var notices: [MailroomPost] { MailroomPresentation.notices(in: graph) } + private var letters: [MailroomPost] { MailroomPresentation.letters(in: graph) } private var unread: Int { - MailroomPresentation.unreadNoteCount(graph: graph, seenPostID: seenPostID) + MailroomPresentation.unreadNoticeCount(graph: graph, seenPostID: seenPostID) } /// The id the unread rule is drawn above — the first note that landed after the human diff --git a/graphcode/Tests/MailroomBudgetTests.swift b/graphcode/Tests/MailroomBudgetTests.swift index 70a037b8..a7fbcf58 100644 --- a/graphcode/Tests/MailroomBudgetTests.swift +++ b/graphcode/Tests/MailroomBudgetTests.swift @@ -44,15 +44,15 @@ struct MailroomBudgetTests { await store.handle( .mailroomPost(text: "DEAD END: approach X fails", topic: "findings", from: ids[0])) - for index in 0..<(Mailroom.maxRecords * 4) { + for index in 0..<(Mailroom.maxLetters * 4) { await store.handle( .messageNode(ids[1], text: "ping \(index)", from: ids[0], followUp: true)) } let board = await store.graph.mailroom #expect(board.contains { $0.body.contains("DEAD END") }) - #expect(board.filter { $0.kind == .record }.count == Mailroom.maxRecords) - #expect(board.filter { $0.kind == .note }.count == 1) + #expect(board.filter { $0.kind == .letter }.count == Mailroom.maxLetters) + #expect(board.filter { $0.kind == .notice }.count == 1) } /// And the converse: notes fill their own budget without evicting the records a loop @@ -63,34 +63,34 @@ struct MailroomBudgetTests { let ids = await ids(store) await store.handle(.messageNode(ids[1], text: "the API changed", from: ids[0], followUp: true)) - for index in 0..<(Mailroom.maxNotes + 10) { + for index in 0..<(Mailroom.maxNotices + 10) { await store.handle(.mailroomPost(text: "note \(index)", topic: nil, from: ids[0])) } let board = await store.graph.mailroom - #expect(board.filter { $0.kind == .record }.count == 1) - #expect(board.filter { $0.kind == .note }.count == Mailroom.maxNotes) + #expect(board.filter { $0.kind == .letter }.count == 1) + #expect(board.filter { $0.kind == .notice }.count == Mailroom.maxNotices) // Ids still only grow, so no cursor mistakes an old post for new mail. - #expect(board.last?.id == Mailroom.maxNotes + 11) + #expect(board.last?.id == Mailroom.maxNotices + 11) } @Test func pruningKeepsTheBoardInOneSequence() { let base = Date() var posts: [MailroomPost] = [] - for index in 1...(Mailroom.maxRecords + 4) { + for index in 1...(Mailroom.maxLetters + 4) { posts.append( MailroomPost( id: index, at: base, authorID: nil, author: "a human", topic: nil, - body: "r\(index)", kind: .record)) + body: "r\(index)", kind: .letter)) posts.append( MailroomPost( id: index + 1000, at: base, authorID: nil, author: "a human", topic: nil, - body: "n\(index)", kind: .note)) + body: "n\(index)", kind: .notice)) } let pruned = Mailroom.pruned(posts.sorted { $0.id < $1.id }) #expect(pruned == pruned.sorted { $0.id < $1.id }) - #expect(pruned.filter { $0.kind == .record }.count == Mailroom.maxRecords) + #expect(pruned.filter { $0.kind == .letter }.count == Mailroom.maxLetters) } /// A board written before records had a kind decodes as all notes — everything on it @@ -101,7 +101,7 @@ struct MailroomBudgetTests { {"id":3,"at":747000000,"author":"Author","body":"hello"} """ let post = try JSONDecoder().decode(MailroomPost.self, from: Data(json.utf8)) - #expect(post.kind == .note) + #expect(post.kind == .notice) #expect(post.authorID == nil) } diff --git a/graphcode/Tests/MailroomSinceYouLookedTests.swift b/graphcode/Tests/MailroomSinceYouLookedTests.swift index f64cd506..f08a645b 100644 --- a/graphcode/Tests/MailroomSinceYouLookedTests.swift +++ b/graphcode/Tests/MailroomSinceYouLookedTests.swift @@ -47,9 +47,9 @@ struct MailroomSinceYouLookedTests { @Test func unreadIsTheHumansNotTheLoops() { let graph = makeGraph(noteIDs: [1, 2, 3], loopCursor: 3) - #expect(MailroomPresentation.unreadNoteCount(graph: graph, seenPostID: nil) == 3) - #expect(MailroomPresentation.unreadNoteCount(graph: graph, seenPostID: 2) == 1) - #expect(MailroomPresentation.unreadNoteCount(graph: graph, seenPostID: 3) == 0) + #expect(MailroomPresentation.unreadNoticeCount(graph: graph, seenPostID: nil) == 3) + #expect(MailroomPresentation.unreadNoticeCount(graph: graph, seenPostID: 2) == 1) + #expect(MailroomPresentation.unreadNoticeCount(graph: graph, seenPostID: 3) == 0) } @Test @@ -96,7 +96,7 @@ struct MailroomSinceYouLookedTests { graph.mailroom.append( MailroomPost( id: 3, at: Date(), authorID: nil, author: "a human", topic: "direct", - body: "@Worker: hi", kind: .record)) + body: "@Worker: hi", kind: .letter)) let store = makeStore(graph: graph, railVisible: true) await store.send(.workspaceLeft) diff --git a/graphcode/Tests/MailroomTests.swift b/graphcode/Tests/MailroomTests.swift index 7a21d7f4..bf9d8ee0 100644 --- a/graphcode/Tests/MailroomTests.swift +++ b/graphcode/Tests/MailroomTests.swift @@ -91,14 +91,14 @@ struct MailroomTests { let store = await makeStore() let ids = nodeIDs(await store.graph) - for index in 0..<(Mailroom.maxNotes + 5) { + for index in 0..<(Mailroom.maxNotices + 5) { await store.handle(.mailroomPost(text: "post \(index)", topic: nil, from: ids[0])) } let graph = await store.graph - #expect(graph.mailroom.count == Mailroom.maxNotes) + #expect(graph.mailroom.count == Mailroom.maxNotices) #expect(graph.mailroom.first?.body == "post 5") - #expect(graph.mailroom.last?.id == Mailroom.maxNotes + 5) + #expect(graph.mailroom.last?.id == Mailroom.maxNotices + 5) } @Test diff --git a/graphcode/Tests/RemoteCLIShimTests.swift b/graphcode/Tests/RemoteCLIShimTests.swift index 78b99bc9..8ad780d2 100644 --- a/graphcode/Tests/RemoteCLIShimTests.swift +++ b/graphcode/Tests/RemoteCLIShimTests.swift @@ -247,7 +247,7 @@ extension RemoteCLIShimTests { // Python's json.dumps does not, so a fixture without one lets `--json` drift // apart silently. This is the body that catches it. body: "claiming issue #12 — see docs/281.md and https://example.test/a/b", - kind: .record), + kind: .letter), ] return (graph, reader) } From 26b6371a7e7c98278cb17475c071f04c32b4637d Mon Sep 17 00:00:00 2001 From: scgopi Date: Fri, 4 Sep 2026 21:13:57 -0700 Subject: [PATCH 3/6] Read the old spellings: settings, graph, cursor, ramp, and both CLI verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rename that drops what is already on disk is a data loss with a changelog entry. Every persisted Artifactory spelling is still read, and none is written back: - artifactoryEnabled, lastArtifactoryRead, artifactoryWatch and the graph's artifactory array decode through a dynamic legacy key. Extra CodingKeys cases were the obvious route and the wrong one — LoopNode and GraphcodeSettings let the compiler synthesise encode(to:), which requires a property per case, and a legacy key must never be written or the old spelling outlives the rename in every file we touch. A legacy value that will not decode falls back to the default rather than failing the read: for a graph that is "no saved graph". - The ramp falls back to the artifactory key. ramps.json is fetched from graphcode.app, so a build knowing only the new spelling would lose the kill switch the moment it shipped ahead of the deployed file; docs/ramps.json publishes both until it has. - `graphcode mail` is the verb; `mailroom` and `artifactory` are accepted and undocumented, as `inbox` is now the verb and `sync` the alias. Live loops carry the old spellings in briefings and memory logs written before today. Same aliases in the remote Python shim. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AtVtkzZNE5fLPeHVqM7A3K --- .../Sources/CLI/GraphcodeCommand.swift | 47 ++++++++++--------- .../Sources/Domain/GraphcodeSettings.swift | 3 +- GraphcodeKit/Sources/Domain/LoopGraph.swift | 4 +- GraphcodeKit/Sources/Domain/LoopNode.swift | 13 +++-- .../Domain/MailroomLegacyDecoding.swift | 28 +++++++++++ .../Sources/Domain/SessionBriefing.swift | 10 ++-- GraphcodeKit/Sources/GraphStore.swift | 26 +++++----- GraphcodeKit/Sources/IPC/DaemonProtocol.swift | 8 ++-- .../Sources/Sessions/MessageBus.swift | 2 +- .../Sources/Sessions/NodeMemory.swift | 2 +- .../Sources/Sessions/RemoteGraphAccess.swift | 35 +++++++------- docs/ramps.json | 4 ++ graphcode-cli/Sources/main.swift | 14 +++--- graphcode/Sources/Clients/FeatureRamps.swift | 15 +++++- .../LoopWorkspace/LoopWorkspaceFeature.swift | 2 +- graphcode/Tests/MailroomBudgetTests.swift | 12 ++--- graphcode/Tests/MailroomCommandTests.swift | 8 ++-- graphcode/Tests/MailroomTests.swift | 10 ++-- graphcode/Tests/RemoteCLIShimTests.swift | 10 ++-- 19 files changed, 154 insertions(+), 99 deletions(-) create mode 100644 GraphcodeKit/Sources/Domain/MailroomLegacyDecoding.swift diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index 22f7fb98..ea15e0a1 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -58,7 +58,7 @@ public enum GraphcodeCommand: Equatable, Sendable { /// `--mark` advances the cursor without printing the backlog ("start me from now"); /// `--json` emits the unread posts machine-readably; `--full` insists on every body /// where the verb would otherwise triage a large backlog down to headlines itself. - case mailroomSync( + case mailroomInbox( projectPath: String, headlines: Bool, mark: Bool, json: Bool, full: Bool) /// One post in full, by id — the deep-read half of `sync --headlines` triage. /// Read-only: the post is in the snapshot, no command reaches the daemon. @@ -101,10 +101,10 @@ public enum GraphcodeCommand: Equatable, Sendable { graphcode node pilot dry-run a composite graphcode node arm arm it (needs a pilot first) graphcode edge create [--kind ] [--condition ] - graphcode mailroom post [--topic ] - leave a note on the shared board for whoever comes next - graphcode mailroom sync [--headlines] [--full] [--mark] [--json] - read your unread posts and mark the board read. A large + graphcode mail post [--topic ] + post a notice to the whole graph, for whoever comes next + graphcode mail inbox [--headlines] [--full] [--mark] [--json] + read your unread mail and mark the room read. A large backlog prints as headlines on its own and says so — --full insists on every body, --headlines insists on triage lines (deep-read either with `read`). --mark @@ -112,12 +112,12 @@ public enum GraphcodeCommand: Equatable, Sendable { the machine-readable shape. Combined, the output wins in the order --json > --mark > --headlines > --full; the cursor advances whichever flags you pass - graphcode mailroom read + graphcode mail read one post in full — the deep-read half of --headlines - graphcode mailroom list [--search ] [--json] - the whole board, read-only — no cursor moves; --search + graphcode mail list [--search ] [--json] + the whole room, read-only — no cursor moves; --search filters by substring across author, topic and body - graphcode mailroom watch [--topic ] [--off] + graphcode mail watch [--topic ] [--off] have matching posts typed into this loop's session as they land; --off stops watching graphcode usage @@ -255,9 +255,9 @@ public enum GraphcodeCommand: Equatable, Sendable { graphcode status graphcode node send --follow-up stage work without interrupting an active turn - graphcode mailroom sync + graphcode mail inbox check what other loops left for you before starting a pass - graphcode mailroom post --topic claims issue #12 is mine + graphcode mail post --topic claims issue #12 is mine stake a claim where every loop will find it, addressed to no one graphcode node pilot graphcode node arm @@ -402,7 +402,9 @@ public enum GraphcodeCommand: Equatable, Sendable { throw ParseError.unknownCommand("node \(verb)") } - case "mailroom": + // `artifactory` is what this shipped as, and live loops carry the old verb in + // briefings and memory logs written before the rename. Accepted, undocumented. + case "mail", "mailroom", "artifactory": return try parseMailroom(&arguments) case "edge": @@ -847,12 +849,12 @@ extension GraphcodeCommand { return projects.map { "\($0.name) \($0.path)" }.joined(separator: "\n") } - /// The board for a terminal. `mailroom list` prints the whole thing (`reader` - /// nil); `mailroom sync` passes the reading loop's id and prints only what its + /// 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 `mailroom read `); `search` keeps only posts whose author, + /// 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. @@ -881,7 +883,7 @@ extension GraphcodeCommand { : "no unread posts match '\(search)'" } return readerID == nil - ? "the board is empty — post one: graphcode mailroom post " + ? "the board is empty — post one: graphcode mail post " : "no unread posts" } // `sync` asks to be triaged; `--headlines` and `--full` are the two ways to say @@ -894,7 +896,7 @@ extension GraphcodeCommand { if triaged { header += " — headlines only, that is a lot to read at once. " - + "Full text: graphcode mailroom read \(graph.project.path) " + + "Full text: graphcode mail read \(graph.project.path) " } var lines = [header] for post in posts { @@ -975,7 +977,7 @@ extension GraphcodeCommand { } /// The triage line — everything `render` says about a post's identity, with the - /// body cut to a glance. The pair (`sync --headlines`, `mailroom read `) is + /// body cut to a glance. The pair (`sync --headlines`, `mail read `) is /// how a loop joining after forty messages spends forty lines instead of forty /// kilobytes, and deep-reads only the posts that turned out to matter. public static func renderHeadline(_ post: MailroomPost) -> String { @@ -987,7 +989,7 @@ extension GraphcodeCommand { return String(full.prefix(budget)) + "…" } - /// `mailroom post`'s answer — the sequence number is what the author's own log and + /// `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" } @@ -1096,7 +1098,7 @@ extension GraphcodeCommand { /// The `mailroom` verbs' parsing, split from `parseVerb` the way export/import /// were. The note is joined argv words — the `node send`/`node memo` bargain, so - /// `graphcode mailroom post --topic claims issue #12 is mine` needs no + /// `graphcode mail post --topic claims issue #12 is mine` needs no /// quoting gymnastics — with `--topic ` riding along in either position. fileprivate static func parseMailroom( _ arguments: inout [String] @@ -1119,10 +1121,11 @@ extension GraphcodeCommand { guard !text.isEmpty else { throw ParseError.missingArgument("note") } return .mailroomPost(projectPath: path, topic: flags["topic"], text: text) - case "sync": + // `sync` is the pre-rename spelling, kept for the same reason the verb itself is. + case "inbox", "sync": try validateFlags(arguments, allowed: ["headlines", "mark", "json", "full"]) let flags = parseFlags(arguments) - return .mailroomSync( + return .mailroomInbox( projectPath: path, headlines: flags["headlines"] != nil, mark: flags["mark"] != nil, json: flags["json"] != nil, full: flags["full"] != nil) diff --git a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift index 7c6c7d96..8cab0dfc 100644 --- a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift +++ b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift @@ -475,7 +475,8 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { // machine or a hand-edited file gets the board the way every install does, and // the app still writes an explicit value the moment a human flips the switch. mailroomEnabled = - try container.decodeIfPresent(Bool.self, forKey: .mailroomEnabled) ?? true + try container.decodeIfPresent(Bool.self, forKey: .mailroomEnabled) + ?? decoder.legacyMailroomValue(Bool.self, "artifactoryEnabled") ?? true // Absent means nobody has asked for it, which is the default. An update must never // start holding a power assertion on a machine whose owner did not choose that. keepsMacAwakeWhileLoopsRun = diff --git a/GraphcodeKit/Sources/Domain/LoopGraph.swift b/GraphcodeKit/Sources/Domain/LoopGraph.swift index d1b30140..be211745 100644 --- a/GraphcodeKit/Sources/Domain/LoopGraph.swift +++ b/GraphcodeKit/Sources/Domain/LoopGraph.swift @@ -269,7 +269,9 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { scope = LoopGraphScope(projectPath: ref.path, name: ref.name) nodes = try container.decodeIfPresent(IdentifiedArrayOf.self, forKey: .nodes) ?? [] edges = try container.decodeIfPresent(IdentifiedArrayOf.self, forKey: .edges) ?? [] - mailroom = try container.decodeIfPresent([MailroomPost].self, forKey: .mailroom) ?? [] + mailroom = + try container.decodeIfPresent([MailroomPost].self, forKey: .mailroom) + ?? decoder.legacyMailroomValue([MailroomPost].self, "artifactory") ?? [] } public func encode(to encoder: Encoder) throws { diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index 187462b5..b1d134a0 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -174,13 +174,13 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// for why only those two types do. `nil` for every snapshot loop. public var templateFollow: TemplateFollow? /// The newest Mailroom post this loop has read — `MailroomPost.id` of the last - /// post a `graphcode mailroom sync` showed it. `nil` has not synced yet and makes + /// post a `graphcode mail inbox` showed it. `nil` has not synced yet and makes /// every post unread; the cursor only moves through sync, so a loop that ignores /// the board accrues nothing but a number, and a loop that died with unread mail /// finds it still waiting at the next wake. public var lastMailroomRead: Int? /// This loop's standing subscription to its project's Mailroom — set and cleared - /// with `graphcode mailroom watch`. Non-nil means every matching post also gets + /// with `graphcode mail watch`. Non-nil means every matching post also gets /// delivered to this loop the way a `--follow-up` message is: typed into a live /// idle session, staged to a busy one's memory, waiting in the post itself for a /// loop that is gone. The post is the durable half; this is only the ding. @@ -541,9 +541,12 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { TemplateFollow.self, forKey: .templateFollow) // Absent from graphs saved before the Mailroom existed — every loop simply has // not read anything yet, which is what `nil` says. - lastMailroomRead = try container.decodeIfPresent(Int.self, forKey: .lastMailroomRead) - mailroomWatch = try container.decodeIfPresent( - MailroomWatch.self, forKey: .mailroomWatch) + lastMailroomRead = + try container.decodeIfPresent(Int.self, forKey: .lastMailroomRead) + ?? decoder.legacyMailroomValue(Int.self, "lastArtifactoryRead") + mailroomWatch = + try container.decodeIfPresent(MailroomWatch.self, forKey: .mailroomWatch) + ?? decoder.legacyMailroomValue(MailroomWatch.self, "artifactoryWatch") stallReason = try container.decodeIfPresent(String.self, forKey: .stallReason) state = try container.decodeIfPresent(LoopState.self, forKey: .state) ?? .idle createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() diff --git a/GraphcodeKit/Sources/Domain/MailroomLegacyDecoding.swift b/GraphcodeKit/Sources/Domain/MailroomLegacyDecoding.swift new file mode 100644 index 00000000..a383a1fc --- /dev/null +++ b/GraphcodeKit/Sources/Domain/MailroomLegacyDecoding.swift @@ -0,0 +1,28 @@ +import Foundation + +/// The spellings a file written before the Artifactory → Mailroom rename uses. +/// +/// A dynamic key rather than extra `CodingKeys` cases, for a reason that is easy to +/// discover the hard way: `LoopNode` and `GraphcodeSettings` both hand-write +/// `init(from:)` and let the compiler synthesise `encode(to:)`, and synthesis requires +/// every case to have a matching property. A legacy key has none — and must be read +/// and never written, or the old spelling would outlive the rename in every file the +/// app touches. +private struct MailroomLegacyKey: CodingKey { + let stringValue: String + var intValue: Int? { nil } + init(_ stringValue: String) { self.stringValue = stringValue } + init?(stringValue: String) { self.init(stringValue) } + init?(intValue: Int) { nil } +} + +extension Decoder { + /// What an older file stored under `key`, or nil — never a throw. A legacy value that + /// will not decode falls back to the current default instead of failing the whole + /// read, which for a graph means `ProjectPersistence` reporting "no saved graph" and + /// for settings means every other preference resetting alongside it. + func legacyMailroomValue(_ type: T.Type, _ key: String) -> T? { + guard let container = try? container(keyedBy: MailroomLegacyKey.self) else { return nil } + return (try? container.decodeIfPresent(type, forKey: MailroomLegacyKey(key))) ?? nil + } +} diff --git a/GraphcodeKit/Sources/Domain/SessionBriefing.swift b/GraphcodeKit/Sources/Domain/SessionBriefing.swift index 41bb5e70..a9ece167 100644 --- a/GraphcodeKit/Sources/Domain/SessionBriefing.swift +++ b/GraphcodeKit/Sources/Domain/SessionBriefing.swift @@ -96,11 +96,11 @@ public enum SessionBriefing { something a peer or successor should not have to rediscover: ```sh - graphcode mailroom sync \(projectPath) # read what you have not seen, mark it read - graphcode mailroom read \(projectPath) # one post in full - graphcode mailroom post \(projectPath) [--topic ] # leave something behind - graphcode mailroom list \(projectPath) # read-only peek, cursor untouched - graphcode mailroom watch \(projectPath) [--topic ] # ring me when new mail lands + graphcode mail inbox \(projectPath) # read what you have not seen, mark it read + graphcode mail read \(projectPath) # one post in full + graphcode mail post \(projectPath) [--topic ] # leave something behind + graphcode mail list \(projectPath) # read-only peek, cursor untouched + graphcode mail watch \(projectPath) [--topic ] # ring me when new mail lands ``` Post decisions made, dead ends hit, claims staked ("I'm taking issue #12") — diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index 077789fb..ed302a57 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -643,8 +643,8 @@ public actor GraphStore { case .mailroomPost(let text, let topic, let from): await mailroomPost(text: text, topic: topic, from: from) - case .mailroomSync(let from): - mailroomSync(from: from) + case .mailroomInbox(let from): + mailroomInbox(from: from) case .mailroomWatch(let on, let topic, let from): mailroomWatch(on: on, topic: topic, from: from) @@ -1581,7 +1581,7 @@ public actor GraphStore { /// Drops a note onto the shared board. Unaddressed by design: there is no target /// id, no edge, no delivery guarantee to any *specific* loop — the post lands on /// the graph, watchers get their best-effort ding, and every future reader finds - /// it with one `mailroom sync`. + /// it with one `mail inbox`. private func mailroomPost(text: String, topic: String?, from senderID: UUID?) async { guard mailroomIsOn() else { announceError( @@ -1591,12 +1591,12 @@ public actor GraphStore { } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { - announceError("mailroom post refused: empty note") + announceError("mail post refused: empty note") return } guard trimmed.utf8.count <= MailroomPost.maxBodyBytes else { announceError( - "mailroom post refused: \(trimmed.utf8.count) bytes is over the " + "mail post refused: \(trimmed.utf8.count) bytes is over the " + "\(MailroomPost.maxBodyBytes)-byte bound — a post is a note to a peer, not " + "a document; put the document in the repo and post the path") return @@ -1605,12 +1605,12 @@ public actor GraphStore { topic.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } ?? Optional.none if let trimmedTopic, trimmedTopic.isEmpty { - announceError("mailroom post refused: an empty topic is no topic — omit it") + announceError("mail post refused: an empty topic is no topic — omit it") return } guard trimmedTopic?.utf8.count ?? 0 <= MailroomPost.maxTopicBytes else { announceError( - "mailroom post refused: topic over \(MailroomPost.maxTopicBytes) bytes") + "mail post refused: topic over \(MailroomPost.maxTopicBytes) bytes") return } // A foreign loop's id (a sender from another graph, addressing this board @@ -1649,7 +1649,7 @@ public actor GraphStore { ? String(post.body.prefix(140)) + "…" : post.body let nudge = "mailroom — new post #\(post.id)\(topicSuffix(post)) from \(post.author): " - + "\(preview) — read it with: graphcode mailroom sync \(graph.project.path)" + + "\(preview) — read it with: graphcode mail inbox \(graph.project.path)" await deliverAdHocMessage( to: node.id, text: nudge, from: nil, followUp: true, mirror: false) } @@ -1686,9 +1686,9 @@ public actor GraphStore { } /// Advances the reading loop's cursor to the newest post — the write half of - /// `graphcode mailroom sync`. Deliberately no memory record: sync is reading, + /// `graphcode mail inbox`. Deliberately no memory record: sync is reading, /// not learning, and a log line per read would turn the log into a metronome. - private func mailroomSync(from readerID: UUID?) { + private func mailroomInbox(from readerID: UUID?) { guard mailroomIsOn() else { announceError( "the Mailroom is off — enable Mailroom in Settings " @@ -1697,7 +1697,7 @@ public actor GraphStore { } guard let readerID, graph.nodes[id: readerID] != nil else { announceError( - "mailroom sync needs a loop identity — run it from a loop's session " + "mail inbox needs a loop identity — run it from a loop's session " + "($ZMX_SESSION); a human reading the board needs no cursor") return } @@ -1723,7 +1723,7 @@ public actor GraphStore { } guard let watcherID, graph.nodes[id: watcherID] != nil else { announceError( - "mailroom watch needs a loop identity — run it from a loop's session " + "mail watch needs a loop identity — run it from a loop's session " + "($ZMX_SESSION); the watcher is the loop the mail is delivered to") return } @@ -1732,7 +1732,7 @@ public actor GraphStore { topic.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } ?? Optional.none if let trimmed, trimmed.isEmpty { - announceError("mailroom watch refused: an empty topic is no topic — omit it") + announceError("mail watch refused: an empty topic is no topic — omit it") return } graph.nodes[id: watcherID]?.mailroomWatch = MailroomWatch(topic: trimmed) diff --git a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift index c8ed21c1..7a260f4d 100644 --- a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift +++ b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift @@ -131,7 +131,7 @@ public indirect enum GraphCommand: Codable, Sendable, Equatable { /// immediate send they always were. case messageNode(UUID, text: String, from: UUID?, followUp: Bool?) /// Drop a note onto the project's Mailroom — the shared, unaddressed board (`graphcode - /// mailroom post`) any loop can write to for *whoever comes next*, without naming a + /// mail post`) any loop can write to for *whoever comes next*, without naming a /// recipient or drawing an edge first. `topic` groups threads for watchers; `from` is /// attributed exactly as `messageNode`'s is (`ZMX_SESSION`), or `nil` from a human's /// shell. Refused outright while the beta ramp has the Mailroom off @@ -139,14 +139,14 @@ public indirect enum GraphCommand: Codable, Sendable, Equatable { /// 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 - /// mailroom sync`, the cursor half of reading. The CLI reads the board out of the + /// 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. - case mailroomSync(from: UUID?) + case mailroomInbox(from: UUID?) /// Subscribe (`on: true`) or unsubscribe (`on: false`) the calling loop to Mailroom - /// posts — `graphcode mailroom watch`. A watched post is delivered the way a + /// posts — `graphcode mail watch`. A watched post is delivered the way a /// `--follow-up` message is: typed into a live idle session, staged to a busy one's /// memory, and for a loop that is gone, nowhere — the post itself is the durable /// half, waiting at the next wake. `topic` filters; `nil` hears everything. diff --git a/GraphcodeKit/Sources/Sessions/MessageBus.swift b/GraphcodeKit/Sources/Sessions/MessageBus.swift index d38f61a0..e1d92293 100644 --- a/GraphcodeKit/Sources/Sessions/MessageBus.swift +++ b/GraphcodeKit/Sources/Sessions/MessageBus.swift @@ -102,7 +102,7 @@ public enum MessageBus { private static func mailroomPostBody(projectPath: String) -> String { "Before you finish: if you learned something a peer or a successor should not have " + "to rediscover — a dead end, a decision, a claim you staked — leave it on the " - + "board with: graphcode mailroom post \(projectPath) [--topic ] . " + + "board with: graphcode mail post \(projectPath) [--topic ] . " + "One note, not a transcript. If there is nothing worth a peer's time, skip this." } diff --git a/GraphcodeKit/Sources/Sessions/NodeMemory.swift b/GraphcodeKit/Sources/Sessions/NodeMemory.swift index 30c67ce4..d6d6a8ba 100644 --- a/GraphcodeKit/Sources/Sessions/NodeMemory.swift +++ b/GraphcodeKit/Sources/Sessions/NodeMemory.swift @@ -161,7 +161,7 @@ public enum NodeMemory { // remember to, without any per-node data racing into the shared briefing file. lines.append( "The project's Mailroom is on: other loops may have left findings for you. " - + "Check at the start of a pass — graphcode mailroom sync — " + + "Check at the start of a pass — graphcode mail inbox — " + "and post anything a peer or successor should not have to rediscover.") lines.append("") } diff --git a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift index 7ffb9654..7e128530 100644 --- a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift +++ b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift @@ -174,11 +174,11 @@ public enum RemoteGraphAccess { graphcode node delete irreversible; stop is reversible graphcode node send graphcode node memo - graphcode mailroom post [--topic ] - graphcode mailroom sync [--headlines] [--full] [--mark] [--json] - graphcode mailroom read - graphcode mailroom list [--search ] [--json] - graphcode mailroom watch [--topic ] [--off] + graphcode mail post [--topic ] + graphcode mail inbox [--headlines] [--full] [--mark] [--json] + graphcode mail read + graphcode mail list [--search ] [--json] + graphcode mail watch [--topic ] [--off] SAFETY Use `graphcode projects` to discover paths and `graphcode status` before retrying. @@ -482,7 +482,7 @@ public enum RemoteGraphAccess { return "no unread posts match '%s'" % search if reader is not None: return "no unread posts" - return ("the board is empty %s post one: graphcode mailroom post " + return ("the board 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" @@ -490,7 +490,7 @@ public enum RemoteGraphAccess { "" if len(posts) == 1 else "s") if triaged: header += (" %s headlines only, that is a lot to read at once. Full text: " - "graphcode mailroom read %s " + "graphcode mail read %s " % (EM_DASH, project.get("path", ""))) lines = [header] for post in posts: @@ -676,7 +676,7 @@ public enum RemoteGraphAccess { def run_and_report(project, inner, report): # `run_with_verdict` with the acknowledgement computed from the graph that comes - # back rather than fixed in advance -- what `mailroom post` needs to name the + # back rather than fixed in advance -- what `mail post` needs to name the # sequence number the note landed at. daemon = Daemon() project = resolve_project(daemon, project) @@ -769,6 +769,7 @@ public enum RemoteGraphAccess { MAILROOM_FLAGS = { "post": ("topic",), + "inbox": ("headlines", "mark", "json", "full"), "sync": ("headlines", "mark", "json", "full"), "read": (), "list": ("search", "json"), @@ -792,7 +793,7 @@ public enum RemoteGraphAccess { # help anywhere, then the flags that subcommand allows -- so a mistyped flag is # refused here rather than silently ignored on the way to the daemon. if not arguments: - fail("missing mailroom subcommand") + fail("missing mail subcommand") subverb = arguments.pop(0) if wants_help(arguments): print(HELP) @@ -804,7 +805,7 @@ public enum RemoteGraphAccess { print(HELP) return if subverb not in MAILROOM_FLAGS: - fail("unknown command: mailroom %s" % subverb) + fail("unknown command: mail %s" % subverb) for argument in arguments: if argument.startswith("--") and argument[2:] not in MAILROOM_FLAGS[subverb]: fail("unknown option: %s" % argument) @@ -824,19 +825,19 @@ public enum RemoteGraphAccess { run_and_report(project, {"mailroomPost": payload}, render_posted) return - if subverb == "sync": + if subverb in ("inbox", "sync"): reader = self_node_id() if not reader: - fail("mailroom sync needs a loop identity %s run it from inside a loop's " + fail("mail inbox needs a loop identity %s run it from inside a loop's " "session ($ZMX_SESSION); a human reading the board wants `graphcode " - "mailroom list`" % EM_DASH) + "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) - daemon.send(graph_command(project, {"mailroomSync": {"from": reader}})) + daemon.send(graph_command(project, {"mailroomInbox": {"from": reader}})) key, value = daemon.wait_for(["graphChanged", "errorOccurred"]) if key == "errorOccurred": fail(value["_0"]) @@ -867,7 +868,7 @@ public enum RemoteGraphAccess { if post.get("id") == post_id: print(render_post(post)) return - fail("no post #%d on this board %s `graphcode mailroom list %s` shows the " + fail("no post #%d on this board %s `graphcode mail list %s` shows the " "ids that exist" % (post_id, EM_DASH, project)) if subverb == "list": @@ -882,7 +883,7 @@ public enum RemoteGraphAccess { watcher = self_node_id() if not watcher: - fail("mailroom watch needs a loop identity %s run it from inside a loop's " + fail("mail watch needs a loop identity %s run it from inside a loop's " "session ($ZMX_SESSION); the mail is delivered to the loop that watches" % EM_DASH) on = "off" not in flags @@ -921,7 +922,7 @@ public enum RemoteGraphAccess { fail("missing project-path") run_and_print(arguments[0]) return - if verb == "mailroom": + if verb in ("mail", "mailroom", "artifactory"): mailroom(arguments) return if verb != "node": diff --git a/docs/ramps.json b/docs/ramps.json index 2e748ec9..af3bc3ac 100644 --- a/docs/ramps.json +++ b/docs/ramps.json @@ -7,6 +7,10 @@ "mailroom": { "beta": 100, "stable": 100 + }, + "artifactory": { + "beta": 100, + "stable": 100 } } } diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index d9c48c53..9342b54f 100644 --- a/graphcode-cli/Sources/main.swift +++ b/graphcode-cli/Sources/main.swift @@ -373,21 +373,21 @@ do { print(GraphcodeCommand.renderPosted(graph)) } - case .mailroomSync(let projectPath, let headlines, let mark, let json, let full): + case .mailroomInbox(let projectPath, let headlines, let mark, let json, let full): // Attributed like `node send` — and required, the one place an mailroom verb // refuses a human shell up front: the cursor is the calling loop's, so with no // ZMX_SESSION there is nobody to advance it for, and the daemon's refusal would - // arrive only after the round trip. Reading without a cursor is `mailroom list`. + // arrive only after the round trip. Reading without a cursor is `mail list`. let reader = SurfaceRef.nodeID( fromZmxSessionName: ProcessInfo.processInfo.environment["ZMX_SESSION"] ?? "") guard let reader else { fail( - "mailroom sync needs a loop identity — run it from inside a loop's session " - + "($ZMX_SESSION); a human reading the board wants `graphcode mailroom list`") + "mail inbox needs a loop identity — run it from inside a loop's session " + + "($ZMX_SESSION); a human reading the board wants `graphcode mail list`") } let opened = try openProject(projectPath) try client.send( - .graphCommand(projectPath: projectPath, command: .mailroomSync(from: reader))) + .graphCommand(projectPath: projectPath, command: .mailroomInbox(from: reader))) let syncVerdict = try client.waitForEvent { event in switch event { case .graphChanged, .errorOccurred: return true @@ -432,7 +432,7 @@ do { 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 mailroom list \(projectPath)` " + "no post #\(postID) on this board — `graphcode mail list \(projectPath)` " + "shows the ids that exist") } print(GraphcodeCommand.render(post)) @@ -458,7 +458,7 @@ do { fromZmxSessionName: ProcessInfo.processInfo.environment["ZMX_SESSION"] ?? "") guard let watcher else { fail( - "mailroom watch needs a loop identity — run it from inside a loop's session " + "mail watch needs a loop identity — run it from inside a loop's session " + "($ZMX_SESSION); the mail is delivered to the loop that watches") } try openProject(projectPath) diff --git a/graphcode/Sources/Clients/FeatureRamps.swift b/graphcode/Sources/Clients/FeatureRamps.swift index bffae137..0c87c6bc 100644 --- a/graphcode/Sources/Clients/FeatureRamps.swift +++ b/graphcode/Sources/Clients/FeatureRamps.swift @@ -25,6 +25,16 @@ enum FeatureRamps { case codespaces case mailroom + /// The key this feature shipped under before it was renamed. ramps.json is fetched + /// from graphcode.app, so a build that knew only the new spelling would stop seeing + /// the kill switch the moment it shipped ahead of the deployed file. + var legacyRawValue: String? { + switch self { + case .codespaces: return nil + case .mailroom: return "artifactory" + } + } + /// What answers when no ramps.json has ever been fetched (and when the fetch /// fails). Kept in step with the *shipped* ramp state: a feature ramped fully on /// moves its default up too, so an offline first launch isn't the one place it's @@ -59,7 +69,10 @@ enum FeatureRamps { static func isEnabled( _ feature: Feature, configuration: Configuration?, channel: String, installID: String ) -> Bool { - let percents = configuration?.features[feature.rawValue] ?? feature.defaultPercents + let percents = + configuration?.features[feature.rawValue] + ?? feature.legacyRawValue.flatMap { configuration?.features[$0] } + ?? feature.defaultPercents let percent = percents[channel] ?? feature.defaultPercents[channel] ?? 0 return bucket(installID: installID) < percent } diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift index 197da7eb..4d35a707 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift @@ -61,7 +61,7 @@ struct LoopWorkspaceFeature { /// The newest board post that was on screen when a workspace in this project was /// last left — the board's counterpart to `seenBeatID`, and a fact about the /// person at the screen for the same reason. It is *not* the loop's own - /// `lastMailroomRead`: that cursor moves when the loop runs `mailroom sync`, + /// `lastMailroomRead`: that cursor moves when the loop runs `mail inbox`, /// which nobody can do from the app, and the rail is what a human reads. Loaded /// from defaults by whoever builds this state (`AppFeature`), per project. var seenMailroomPostID: Int? diff --git a/graphcode/Tests/MailroomBudgetTests.swift b/graphcode/Tests/MailroomBudgetTests.swift index a7fbcf58..953f93a1 100644 --- a/graphcode/Tests/MailroomBudgetTests.swift +++ b/graphcode/Tests/MailroomBudgetTests.swift @@ -182,7 +182,7 @@ struct MailroomBudgetTests { graph, unreadFor: reader.id, autoTriage: true) #expect(rendered.contains("headlines only")) - #expect(rendered.contains("mailroom read /tmp/p ")) + #expect(rendered.contains("mail read /tmp/p ")) } @Test @@ -220,12 +220,12 @@ struct MailroomBudgetTests { let full = try GraphcodeCommand.parse(["mailroom", "sync", "/tmp/p", "--full"]) #expect( full - == .mailroomSync( + == .mailroomInbox( projectPath: "/tmp/p", headlines: false, mark: false, json: false, full: true)) let plain = try GraphcodeCommand.parse(["mailroom", "sync", "/tmp/p"]) #expect( plain - == .mailroomSync( + == .mailroomInbox( projectPath: "/tmp/p", headlines: false, mark: false, json: false, full: false)) } @@ -236,7 +236,7 @@ struct MailroomBudgetTests { @Test func aResolvingLoopIsAskedToLeaveANote() { let ask = MessageBus.resolutionAsk(distillSkill: false, mailroomProjectPath: "/tmp/p") - #expect(ask?.contains("graphcode mailroom post /tmp/p") == true) + #expect(ask?.contains("graphcode mail post /tmp/p") == true) #expect(ask?.hasPrefix("[graphcode] ") == true) } @@ -245,7 +245,7 @@ struct MailroomBudgetTests { func bothAsksArriveAsOneInterruption() { let ask = MessageBus.resolutionAsk(distillSkill: true, mailroomProjectPath: "/tmp/p") #expect(ask?.contains("distill it into a project skill") == true) - #expect(ask?.contains("graphcode mailroom post") == true) + #expect(ask?.contains("graphcode mail post") == true) #expect(ask?.components(separatedBy: "[graphcode] ").count == 2) } @@ -274,6 +274,6 @@ struct MailroomBudgetTests { await store.handle(.nodeCheckApproved(id)) - #expect(!delivered.value.contains { $0.1.contains("mailroom post") }) + #expect(!delivered.value.contains { $0.1.contains("mail post") }) } } diff --git a/graphcode/Tests/MailroomCommandTests.swift b/graphcode/Tests/MailroomCommandTests.swift index a406f2ea..ae24a37a 100644 --- a/graphcode/Tests/MailroomCommandTests.swift +++ b/graphcode/Tests/MailroomCommandTests.swift @@ -43,7 +43,7 @@ struct MailroomCommandTests { func syncAndListTakeOnlyAProjectPath() throws { #expect( try GraphcodeCommand.parse(["mailroom", "sync", "/tmp/x"]) - == .mailroomSync( + == .mailroomInbox( projectPath: "/tmp/x", headlines: false, mark: false, json: false, full: false)) #expect( try GraphcodeCommand.parse(["mailroom", "list", "/tmp/x"]) @@ -176,7 +176,7 @@ struct MailroomCommandTests { @Test func helpTextTeachesTheMailroomVerbs() { - for verb in ["mailroom post", "mailroom sync", "mailroom list", "mailroom watch"] { + for verb in ["mail post", "mail inbox", "mail list", "mail watch"] { #expect(GraphcodeCommand.helpText.contains(verb)) } } @@ -208,7 +208,7 @@ func syncParsesItsReadModes() throws { let plain = try GraphcodeCommand.parse(["mailroom", "sync", "/tmp/x"]) #expect( plain - == .mailroomSync( + == .mailroomInbox( projectPath: "/tmp/x", headlines: false, mark: false, json: false, full: false)) let all = try GraphcodeCommand.parse([ @@ -216,7 +216,7 @@ func syncParsesItsReadModes() throws { ]) #expect( all - == .mailroomSync( + == .mailroomInbox( projectPath: "/tmp/x", headlines: true, mark: true, json: true, full: false)) #expect { diff --git a/graphcode/Tests/MailroomTests.swift b/graphcode/Tests/MailroomTests.swift index bf9d8ee0..469c33f9 100644 --- a/graphcode/Tests/MailroomTests.swift +++ b/graphcode/Tests/MailroomTests.swift @@ -107,11 +107,11 @@ struct MailroomTests { let ids = nodeIDs(await store.graph) await store.handle(.mailroomPost(text: "one", topic: nil, from: ids[0])) - await store.handle(.mailroomSync(from: ids[1])) + await store.handle(.mailroomInbox(from: ids[1])) var graph = await store.graph #expect(graph.nodes[id: ids[1]]?.lastMailroomRead == 1) - await store.handle(.mailroomSync(from: ids[1])) + await store.handle(.mailroomInbox(from: ids[1])) graph = await store.graph #expect(graph.nodes[id: ids[1]]?.lastMailroomRead == 1) } @@ -120,7 +120,7 @@ struct MailroomTests { func syncNeedsLoopIdentity() async { let store = await makeStore() - await store.handle(.mailroomSync(from: nil)) + await store.handle(.mailroomInbox(from: nil)) let graph = await store.graph #expect(graph.nodes.allSatisfy { $0.lastMailroomRead == nil }) @@ -155,7 +155,7 @@ struct MailroomTests { $0.0 == ids[1] && $0.1.contains("mailroom — new post #1 (build) from Author") } #expect(staged.count == 1) - #expect(staged[0].1.contains("graphcode mailroom sync")) + #expect(staged[0].1.contains("graphcode mail inbox")) } @Test @@ -485,7 +485,7 @@ extension MailroomTests { let off = SessionBriefing.text( projectPath: "/tmp/p", settings: GraphcodeSettings(mailroomEnabled: false)) #expect(on?.contains("## The Mailroom — notes for whoever comes next") == true) - #expect(on?.contains("graphcode mailroom sync /tmp/p") == true) + #expect(on?.contains("graphcode mail inbox /tmp/p") == true) #expect(off?.contains("## The Mailroom") == false) // Off means byte-for-byte the pre-Mailroom briefing: no stray interpolation // line where the section would have gone. diff --git a/graphcode/Tests/RemoteCLIShimTests.swift b/graphcode/Tests/RemoteCLIShimTests.swift index 8ad780d2..23b70cea 100644 --- a/graphcode/Tests/RemoteCLIShimTests.swift +++ b/graphcode/Tests/RemoteCLIShimTests.swift @@ -465,7 +465,7 @@ extension RemoteCLIShimTests { #expect(synced.stdout == "marked read up to #3\n") #expect( synced.commands.dropFirst().first - == .graphCommand(projectPath: Self.project, command: .mailroomSync(from: reader.id))) + == .graphCommand(projectPath: Self.project, command: .mailroomInbox(from: reader.id))) let watching = try runShim( ["mailroom", "watch", Self.project, "--topic", "build"], environment: session, @@ -602,12 +602,12 @@ extension RemoteCLIShimTests { func theCursorVerbsNeedALoopIdentityAndMailroomIsNoLongerMacOnly() throws { let sync = try runShim(["mailroom", "sync", Self.project]) #expect(sync.status == 1) - #expect(sync.stderr.contains("mailroom sync needs a loop identity")) - #expect(sync.stderr.contains("graphcode mailroom list")) + #expect(sync.stderr.contains("mail inbox needs a loop identity")) + #expect(sync.stderr.contains("graphcode mail list")) let watch = try runShim(["mailroom", "watch", Self.project]) #expect(watch.status == 1) - #expect(watch.stderr.contains("mailroom watch needs a loop identity")) + #expect(watch.stderr.contains("mail watch needs a loop identity")) #expect(watch.stderr.contains("the mail is delivered to the loop that watches")) // Neither reached the daemon, so nothing was applied and nothing needs undoing. @@ -634,7 +634,7 @@ extension RemoteCLIShimTests { let missing = try runShim(["mailroom", "read", Self.project, "99"], graph: graph) #expect(missing.status == 1) #expect(missing.stderr.contains("no post #99 on this board")) - #expect(missing.stderr.contains("graphcode mailroom list")) + #expect(missing.stderr.contains("graphcode mail list")) let negative = try runShim(["mailroom", "read", Self.project, "-7"], graph: graph) #expect(negative.status == 1) From 548fd14849b51c847594355f076c084e5ebc3949 Mon Sep 17 00:00:00 2001 From: scgopi Date: Fri, 4 Sep 2026 21:26:47 -0700 Subject: [PATCH 4/6] Give the Mailroom half the rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section was sized when it held notices alone: 40% of the rail, capped at 600pt. Now that a graph's correspondence lands here too, a laptop rail showed about four posts, which makes a section you have to scroll before it tells you anything — and the point of putting the room in the rail was that a coordination channel the supervisor never sees is the failure mode it was drawn from. Half the rail, floor 200 (two posts rather than a sliver), ceiling 820 (about fourteen). Still a share and not a fixed height, so the #245 invariant holds: at the shortest rail a 1280x800 window produces, the room plus everything rigid above it comes to 660 of 700 points, and MailroomRailShareTests pins it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AtVtkzZNE5fLPeHVqM7A3K --- .../LoopWorkspace/LoopWorkspaceRail.swift | 14 ++++- .../LoopWorkspace/MailroomSection.swift | 55 ++++++++++--------- graphcode/Tests/MailroomRailShareTests.swift | 16 +++--- 3 files changed, 48 insertions(+), 37 deletions(-) diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift index 5edb3a9a..11826f29 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift @@ -204,9 +204,19 @@ struct LoopWorkspaceRail: View { /// greedy and yield; the board hugs its posts with `fixedSize` and does not. On a /// short window a fixed 600pt cap was enough, with THIS LOOP's 118 and the summary's /// 120 floor, to overflow the stack and push the foot of the rail off the bottom. - /// A share cannot overflow on its own, and 40% still shows a conversation. + /// A share cannot overflow on its own. + /// + /// Half, not the original 40%: the room now carries a graph's correspondence as well + /// as its notices, and four visible posts on a laptop rail made a section you had to + /// scroll to learn anything from. The invariant is unchanged and still pinned by + /// `MailroomRailShareTests` — the share plus everything rigid above it fits the + /// shortest rail a 1280×800 window produces. + static let railShare: CGFloat = 0.5 + /// Two posts, so a very short rail shows a conversation and not a sliver. + static let minMailroomHeight: CGFloat = 200 + static func mailroomHeightCap(railHeight: CGFloat) -> CGFloat { - min(MailroomSection.maxScrollHeight, max(160, railHeight * 0.4)) + min(MailroomSection.maxScrollHeight, max(minMailroomHeight, railHeight * railShare)) } var body: some View { diff --git a/graphcode/Sources/Features/LoopWorkspace/MailroomSection.swift b/graphcode/Sources/Features/LoopWorkspace/MailroomSection.swift index 4d917ebb..01418a00 100644 --- a/graphcode/Sources/Features/LoopWorkspace/MailroomSection.swift +++ b/graphcode/Sources/Features/LoopWorkspace/MailroomSection.swift @@ -1,5 +1,5 @@ -import MailroomKit import GraphcodeKit +import MailroomKit import SwiftUI /// What the rail needs to know about the board without building a view to find out. @@ -39,7 +39,7 @@ enum MailroomPresentation { /// The Mailroom in the workspace rail — a peer of `LoopSummarySection` and /// `SummaryBoardSection`, and the one place a human meets the board without a shell. /// -/// The board is how loops leave notes for whoever comes next, and until this section +/// The board is how loops leave notices for whoever comes next, and until this section /// existed the only way to read one was a CLI verb nobody had been told about. That is /// the whole reason it is here rather than behind a menu: a coordination channel a /// supervisor never sees is the failure mode, not a missing convenience. @@ -59,16 +59,17 @@ struct MailroomSection: View { /// which is exactly what a person talking to the whole graph is. let onPost: (String, String?) -> Void - /// Whether the mirrored records are unfolded. Local and unpersisted, unlike the + /// Whether the mirrored letters are unfolded. Local and unpersisted, unlike the /// section's own fold: opening the receipts is a thing you do once to answer a /// question, not a way you prefer to read the board. - /// The most the board's scroll box will ever be, on any window: about ten posts at - /// the rail's default width — enough to read a conversation, not so many that the - /// rail is nothing but the board. The rail hands down a smaller cap on a short - /// window (`LoopWorkspaceRail.mailroomHeightCap`); this is the ceiling on that. - static let maxScrollHeight: CGFloat = 600 - - @State private var showsRecords = false + /// The most the room's scroll box will ever be, on any window: about fourteen posts + /// at the rail's default width — enough to read a conversation without scrolling, + /// not so many that the rail is nothing but the room. The rail hands down a smaller + /// cap on a short window (`LoopWorkspaceRail.mailroomHeightCap`); this is the + /// ceiling on that. + static let maxScrollHeight: CGFloat = 820 + + @State private var showsLetters = false @State private var isComposing = false @State private var draft = "" @State private var draftTopic = "" @@ -84,7 +85,7 @@ struct MailroomSection: View { /// last looked. `nil` when everything is read, which is when nothing should be drawn. private var firstUnreadID: Int? { guard unread > 0 else { return nil } - return notes.suffix(unread).first?.id + return notices.suffix(unread).first?.id } var body: some View { @@ -95,8 +96,8 @@ struct MailroomSection: View { } else { ScrollView(.vertical) { VStack(alignment: .leading, spacing: 11) { - recordsRollup - ForEach(notes) { post in + lettersRollup + ForEach(notices) { post in if post.id == firstUnreadID { sinceYouLooked } postRow(post) } @@ -139,7 +140,7 @@ struct MailroomSection: View { } private var unreadIDs: Set { - Set(notes.suffix(unread).map(\.id)) + Set(notices.suffix(unread).map(\.id)) } // MARK: - Header @@ -178,9 +179,9 @@ struct MailroomSection: View { private var foldedLine: some View { HStack(spacing: 6) { Circle() - .fill(accent(for: notes.last)) + .fill(accent(for: notices.last)) .frame(width: 6, height: 6) - Text(notes.last?.body ?? "no notes yet") + Text(notices.last?.body ?? "no notices yet") .font(.system(size: 11.5)) .foregroundStyle(.white.opacity(0.75)) .lineLimit(1) @@ -235,38 +236,38 @@ struct MailroomSection: View { /// The mirrored traffic, one line each and never a body: a record says that two loops /// spoke, which is all a reader of the board needs from it. @ViewBuilder - private var recordsRollup: some View { - if !records.isEmpty { + private var lettersRollup: some View { + if !letters.isEmpty { VStack(alignment: .leading, spacing: 7) { HStack(spacing: 6) { - Image(systemName: showsRecords ? "chevron.down" : "chevron.right") + Image(systemName: showsLetters ? "chevron.down" : "chevron.right") .font(.system(size: 8, weight: .semibold)) .foregroundStyle(.white.opacity(0.38)) Text( - records.count == 1 ? "1 message record" : "\(records.count) message records" + letters.count == 1 ? "1 letter" : "\(letters.count) letters" ) .font(.system(size: 10.5)) .foregroundStyle(.white.opacity(0.5)) Spacer(minLength: 0) } .contentShape(Rectangle()) - .onTapGesture { showsRecords.toggle() } - if showsRecords { + .onTapGesture { showsLetters.toggle() } + if showsLetters { VStack(alignment: .leading, spacing: 7) { - ForEach(records.suffix(8)) { record in + ForEach(letters.suffix(8)) { letter in HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(MailroomPost.stampFormat.string(from: record.at)) + Text(MailroomPost.stampFormat.string(from: letter.at)) .font(.system(size: 10.5, design: .monospaced)) .foregroundStyle(.white.opacity(0.32)) - Text(record.body) + Text(letter.body) .font(.system(size: 11)) .foregroundStyle(.white.opacity(0.5)) .lineLimit(1) .truncationMode(.tail) } } - if records.count > 8 { - Text("\(records.count - 8) earlier") + if letters.count > 8 { + Text("\(letters.count - 8) earlier") .font(.system(size: 10.5)) .foregroundStyle(.white.opacity(0.4)) } diff --git a/graphcode/Tests/MailroomRailShareTests.swift b/graphcode/Tests/MailroomRailShareTests.swift index d83c5994..63f8c5c3 100644 --- a/graphcode/Tests/MailroomRailShareTests.swift +++ b/graphcode/Tests/MailroomRailShareTests.swift @@ -9,20 +9,20 @@ import Testing struct MailroomRailShareTests { @Test func theBoardTakesAShareOfTheRailNotAFixedHeight() { - // A 900pt rail: 40% is 360, well under the 600 ceiling and well over the floor. - #expect(LoopWorkspaceRail.mailroomHeightCap(railHeight: 900) == 360) - // A tall rail: the share would exceed the ceiling, so the ceiling wins — ten posts - // is enough on any window. - #expect(LoopWorkspaceRail.mailroomHeightCap(railHeight: 2000) == 600) - #expect(MailroomSection.maxScrollHeight == 600) + // A 900pt rail: half is 450, well under the 820 ceiling and well over the floor. + #expect(LoopWorkspaceRail.mailroomHeightCap(railHeight: 900) == 450) + // A tall rail: the share would exceed the ceiling, so the ceiling wins — fourteen + // posts is enough on any window. + #expect(LoopWorkspaceRail.mailroomHeightCap(railHeight: 2000) == 820) + #expect(MailroomSection.maxScrollHeight == 820) } /// A very short rail still shows a couple of posts rather than a sliver; below the /// floor the rail has bigger problems than this section. @Test func aShortRailStillShowsSomething() { - #expect(LoopWorkspaceRail.mailroomHeightCap(railHeight: 300) == 160) - #expect(LoopWorkspaceRail.mailroomHeightCap(railHeight: 0) == 160) + #expect(LoopWorkspaceRail.mailroomHeightCap(railHeight: 300) == 200) + #expect(LoopWorkspaceRail.mailroomHeightCap(railHeight: 0) == 200) } /// The share leaves room for everything rigid above it: THIS LOOP (118) plus the From 12b163584c179945100d7120cf67bc717824bb4b Mon Sep 17 00:00:00 2001 From: scgopi Date: Fri, 4 Sep 2026 21:26:47 -0700 Subject: [PATCH 5/6] Say what the room is, and pin the old spellings with tests The Settings copy used to translate its own label in the first four words ("Artifactory" -> "Loops share a message board"), which was the clearest evidence the name was not carrying. It now describes the thing: letters are what the loops said to each other, notices are addressed to nobody. The briefing and CLI help follow the same two words. MailroomLegacyNamesTests covers what a machine running the old build still has on disk and what its loops still type: a recorded choice to turn the board off, the board itself, a loop's cursor and watch, both Kind spellings, an unknown kind reading as a notice instead of losing the board, the ramp key, and all three verb spellings. Each encodes the current shape and rewrites the keys back rather than hand-writing JSON, so the tests pin the key lookup and not a date strategy. 1584 tests / 163 suites pass (was 1577/162); swiftlint 0 errors, swift-format --strict clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AtVtkzZNE5fLPeHVqM7A3K --- .../Sources/CLI/GraphcodeCommand.swift | 2 +- GraphcodeKit/Sources/Domain/LoopGraph.swift | 2 +- GraphcodeKit/Sources/Domain/LoopNode.swift | 2 +- .../Sources/Domain/SessionBriefing.swift | 27 ++-- GraphcodeKit/Sources/GraphStore.swift | 2 +- .../Features/Settings/SettingsView.swift | 14 ++- graphcode/Tests/MailroomBudgetTests.swift | 2 +- graphcode/Tests/MailroomCommandTests.swift | 2 +- .../Tests/MailroomLegacyNamesTests.swift | 117 ++++++++++++++++++ .../Tests/MailroomSinceYouLookedTests.swift | 2 +- graphcode/Tests/MailroomTests.swift | 6 +- graphcode/Tests/RemoteCLIShimTests.swift | 22 ++-- 12 files changed, 164 insertions(+), 36 deletions(-) create mode 100644 graphcode/Tests/MailroomLegacyNamesTests.swift diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index ea15e0a1..afa3be41 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -1,5 +1,5 @@ -import MailroomKit import Foundation +import MailroomKit /// Argument parsing and output formatting for the `graphcode` CLI /// (docs/03-architecture.md#cli-graphcode). diff --git a/GraphcodeKit/Sources/Domain/LoopGraph.swift b/GraphcodeKit/Sources/Domain/LoopGraph.swift index be211745..adaba123 100644 --- a/GraphcodeKit/Sources/Domain/LoopGraph.swift +++ b/GraphcodeKit/Sources/Domain/LoopGraph.swift @@ -1,6 +1,6 @@ -import MailroomKit import Foundation import IdentifiedCollections +import MailroomKit /// The unit `graphcoded`'s `GraphStore` owns and the graph canvas renders — see /// docs/02-graph-of-loops.md#loopgraph. diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index b1d134a0..42804355 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -1,5 +1,5 @@ -import MailroomKit import Foundation +import MailroomKit /// One node in a graph of loops: a unit of agentic work with a well-defined hand-off /// contract, running inside a real CLI session. See docs/02-graph-of-loops.md. diff --git a/GraphcodeKit/Sources/Domain/SessionBriefing.swift b/GraphcodeKit/Sources/Domain/SessionBriefing.swift index a9ece167..94d3a1dc 100644 --- a/GraphcodeKit/Sources/Domain/SessionBriefing.swift +++ b/GraphcodeKit/Sources/Domain/SessionBriefing.swift @@ -87,7 +87,7 @@ public enum SessionBriefing { ? """ - ## The Mailroom — notes for whoever comes next + ## The Mailroom — the graph's mail, and notices for whoever comes next `node send` reaches one peer you already know. The Mailroom is the shared counterpart: an unaddressed board any loop can post to and any loop can read, @@ -104,19 +104,18 @@ public enum SessionBriefing { ``` Post decisions made, dead ends hit, claims staked ("I'm taking issue #12") — - a note for a peer, not a transcript. Sync before you rely on nobody having - got there first, and watch a topic when you want the board to come to you. - A big backlog prints as one line per post and says so; `read ` then - spends context only on the ones that turned out to matter. - - The board also keeps the record for you: every direct message, message-edge - delivery, and handoff (topics `direct` and `handoff`) is mirrored onto it - automatically, so a loop that joins mid-flight can read what was already said. - Those mirrored records are the record, not the delivery — they never ring a - watcher, so watching only those topics stays silent, and they prune on their - own budget so graph chatter can never crowd out a note. Your posts outlive - you: they stay after you resolve, and after your loop is deleted — only the - byline goes. + a notice for a peer, not a transcript. Check your inbox before you rely on + nobody having got there first, and watch a topic when you want the room to + come to you. A big backlog prints as one line per post and says so; + `read ` then spends context only on the ones that turned out to matter. + + The room also keeps the letters: every direct message, message-edge delivery, + and handoff (topics `direct` and `handoff`) is copied here automatically, so a + loop that joins mid-flight can read what was already said. A letter is the + room's copy, not the delivery — it never rings a watcher, so watching only + those topics stays silent, and letters prune on their own budget so graph + chatter can never crowd out a notice. Your posts outlive you: they stay after + you resolve, and after your loop is deleted — only the byline goes. """ : "" return """ diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index ed302a57..b606cadc 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -1,5 +1,5 @@ -import MailroomKit import Foundation +import MailroomKit /// Owns the daemon's one `LoopGraph`, applies commands, automatically fires `.handoff` /// edges when a node resolves, keeps time-based nodes' sessions alive, and broadcasts diff --git a/graphcode/Sources/Features/Settings/SettingsView.swift b/graphcode/Sources/Features/Settings/SettingsView.swift index 7c5fad2b..abe5d396 100644 --- a/graphcode/Sources/Features/Settings/SettingsView.swift +++ b/graphcode/Sources/Features/Settings/SettingsView.swift @@ -182,12 +182,14 @@ struct SettingsView: View { // off for a person who finds it too much, and is remembered over any rollout. Toggle("Mailroom", isOn: $model.mailroomEnabled) Text( - "Loops share a message board — a note dropped for whoever comes next, " - + "discoverable by loops that didn't exist when it was written — " - + "alongside the addressed `node send` and edges. It also appears in a " - + "loop's workspace rail, where you can read it and leave notes yourself. " - + "Off, the daemon refuses every mailroom command and the rail section " - + "goes away. On by default; your choice here is kept." + "One place for a graph's mail. Letters are what the loops said to each " + + "other — `node send` messages, edge deliveries and handoffs, each " + + "addressed to somebody. Notices are addressed to nobody: a note left " + + "for whoever comes next, found by loops that didn't exist when it was " + + "written. Both appear in a loop's workspace rail, where you can read " + + "them and post notices yourself. Off, the daemon refuses every mail " + + "command and the rail section goes away. On by default; your choice " + + "here is kept." ) .font(.caption2) .foregroundStyle(.secondary) diff --git a/graphcode/Tests/MailroomBudgetTests.swift b/graphcode/Tests/MailroomBudgetTests.swift index 953f93a1..4405ce2b 100644 --- a/graphcode/Tests/MailroomBudgetTests.swift +++ b/graphcode/Tests/MailroomBudgetTests.swift @@ -1,7 +1,7 @@ -import MailroomKit import ComposableArchitecture import Foundation import GraphcodeKit +import MailroomKit import Testing /// The two budgets, the delete that keeps the note, the self-triaging sync, and the diff --git a/graphcode/Tests/MailroomCommandTests.swift b/graphcode/Tests/MailroomCommandTests.swift index ae24a37a..5b71f950 100644 --- a/graphcode/Tests/MailroomCommandTests.swift +++ b/graphcode/Tests/MailroomCommandTests.swift @@ -1,6 +1,6 @@ -import MailroomKit import Foundation import GraphcodeKit +import MailroomKit import Testing /// The `mailroom` verbs' CLI half: what each spelling parses into and what the board diff --git a/graphcode/Tests/MailroomLegacyNamesTests.swift b/graphcode/Tests/MailroomLegacyNamesTests.swift new file mode 100644 index 00000000..6203ea3b --- /dev/null +++ b/graphcode/Tests/MailroomLegacyNamesTests.swift @@ -0,0 +1,117 @@ +import Foundation +import GraphcodeKit +import MailroomKit +import Testing + +@testable import graphcode + +/// What a machine that ran the Artifactory build still has on disk, and what its loops +/// still type. The rename is only safe if every old spelling is read, so these encode +/// the current shape, rewrite the keys back to the ones that shipped, and decode. +/// +/// The round-trip is deliberate: hand-written JSON would pin a date strategy and a +/// field list that drift, and the question here is only whether the *key* is found. +@Suite +struct MailroomLegacyNamesTests { + private static func rewritten(_ value: some Encodable, _ swaps: [(String, String)]) throws + -> Data + { + var json = String(decoding: try JSONEncoder().encode(value), as: UTF8.self) + for (new, old) in swaps { + json = json.replacingOccurrences(of: "\"\(new)\"", with: "\"\(old)\"") + } + return Data(json.utf8) + } + + @Test + func aRecordedChoiceToTurnTheBoardOffSurvivesTheRename() throws { + // The Settings copy promises "your choice here is kept". Missing this key would + // silently switch the section back on for everyone who had turned it off. + let data = try Self.rewritten( + GraphcodeSettings(mailroomEnabled: false), [("mailroomEnabled", "artifactoryEnabled")]) + #expect(try JSONDecoder().decode(GraphcodeSettings.self, from: data).mailroomEnabled == false) + } + + @Test + func theBoardItselfSurvivesTheRename() throws { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/p", name: "p")) + graph.mailroom = [ + MailroomPost( + id: 1, at: Date(), authorID: nil, author: "a human", topic: "claims", body: "taking #12") + ] + let data = try Self.rewritten(graph, [("mailroom", "artifactory")]) + let decoded = try JSONDecoder().decode(LoopGraph.self, from: data) + #expect(decoded.mailroom.map(\.body) == ["taking #12"]) + } + + @Test + func aLoopKeepsItsCursorAndItsWatch() throws { + // A dropped cursor is not cosmetic: it re-delivers the whole board on the next + // read, which is the ~45k-token first sync the triage rule exists to prevent. + let node = LoopNode( + title: "Worker", lastMailroomRead: 7, mailroomWatch: MailroomWatch(topic: "build")) + let data = try Self.rewritten( + node, + [("lastMailroomRead", "lastArtifactoryRead"), ("mailroomWatch", "artifactoryWatch")]) + let decoded = try JSONDecoder().decode(LoopNode.self, from: data) + #expect(decoded.lastMailroomRead == 7) + #expect(decoded.mailroomWatch == MailroomWatch(topic: "build")) + } + + @Test + func postsKeepTheirKindUnderTheOldSpellings() throws { + for (current, shipped, expected) in [ + ("letter", "record", MailroomPost.Kind.letter), ("notice", "note", .notice), + ] { + let post = MailroomPost( + id: 1, at: Date(), authorID: nil, author: "a human", topic: nil, body: "b", + kind: expected) + let data = try Self.rewritten(post, [(current, shipped)]) + #expect(try JSONDecoder().decode(MailroomPost.self, from: data).kind == expected) + } + } + + @Test + func anUnknownKindReadsAsANoticeRatherThanLosingTheBoard() throws { + // ProjectPersistence turns any decode failure into "no saved graph", so throwing + // here would trade one unreadable post for every post on the board. + let post = MailroomPost( + id: 1, at: Date(), authorID: nil, author: "a human", topic: nil, body: "b", kind: .letter) + let data = try Self.rewritten(post, [("letter", "telegram")]) + #expect(try JSONDecoder().decode(MailroomPost.self, from: data).kind == .notice) + } + + @Test + func theKillSwitchStillAnswersUnderTheKeyItShippedWith() { + // ramps.json is fetched from graphcode.app: a build that knew only the new key + // would stop obeying the deployed file the moment it shipped ahead of it. + let deployed = FeatureRamps.Configuration(features: ["artifactory": ["beta": 0, "stable": 0]]) + #expect( + FeatureRamps.isEnabled( + .mailroom, configuration: deployed, channel: "beta", installID: "any") == false) + // And the new key wins wherever the file has caught up. + let renamed = FeatureRamps.Configuration(features: [ + "artifactory": ["beta": 0, "stable": 0], "mailroom": ["beta": 100, "stable": 100], + ]) + #expect( + FeatureRamps.isEnabled( + .mailroom, configuration: renamed, channel: "beta", installID: "any") == true) + } + + @Test + func everyVerbALiveLoopMayHaveBeenTaughtStillParses() throws { + // A loop relaunched today carries whichever spelling its briefing was written + // with, in its memory log and often mid-turn. + for verb in ["mail", "mailroom", "artifactory"] { + #expect( + try GraphcodeCommand.parse([verb, "post", "/tmp/x", "hello"]) + == .mailroomPost(projectPath: "/tmp/x", topic: nil, text: "hello")) + } + for reader in ["inbox", "sync"] { + #expect( + try GraphcodeCommand.parse(["mail", reader, "/tmp/x"]) + == .mailroomInbox( + projectPath: "/tmp/x", headlines: false, mark: false, json: false, full: false)) + } + } +} diff --git a/graphcode/Tests/MailroomSinceYouLookedTests.swift b/graphcode/Tests/MailroomSinceYouLookedTests.swift index f08a645b..0f4a85ed 100644 --- a/graphcode/Tests/MailroomSinceYouLookedTests.swift +++ b/graphcode/Tests/MailroomSinceYouLookedTests.swift @@ -1,7 +1,7 @@ -import MailroomKit import ComposableArchitecture import Foundation import GraphcodeKit +import MailroomKit import Testing @testable import graphcode diff --git a/graphcode/Tests/MailroomTests.swift b/graphcode/Tests/MailroomTests.swift index 469c33f9..0b39f853 100644 --- a/graphcode/Tests/MailroomTests.swift +++ b/graphcode/Tests/MailroomTests.swift @@ -1,7 +1,7 @@ -import MailroomKit import ComposableArchitecture import Foundation import GraphcodeKit +import MailroomKit import Testing /// The Mailroom's daemon half: posting, cursors, subscriptions and watcher wakes. @@ -484,7 +484,9 @@ extension MailroomTests { projectPath: "/tmp/p", settings: GraphcodeSettings(mailroomEnabled: true)) let off = SessionBriefing.text( projectPath: "/tmp/p", settings: GraphcodeSettings(mailroomEnabled: false)) - #expect(on?.contains("## The Mailroom — notes for whoever comes next") == true) + #expect( + on?.contains("## The Mailroom — the graph's mail, and notices for whoever comes next") + == true) #expect(on?.contains("graphcode mail inbox /tmp/p") == true) #expect(off?.contains("## The Mailroom") == false) // Off means byte-for-byte the pre-Mailroom briefing: no stray interpolation diff --git a/graphcode/Tests/RemoteCLIShimTests.swift b/graphcode/Tests/RemoteCLIShimTests.swift index 23b70cea..4dc44045 100644 --- a/graphcode/Tests/RemoteCLIShimTests.swift +++ b/graphcode/Tests/RemoteCLIShimTests.swift @@ -1,5 +1,5 @@ -import MailroomKit import Foundation +import MailroomKit import Testing @testable import GraphcodeKit @@ -614,14 +614,22 @@ extension RemoteCLIShimTests { #expect(sync.commands.isEmpty) #expect(watch.commands.isEmpty) - for verb in ["post", "sync", "read", "list", "watch"] { - let run = try runShim(["mailroom", verb]) - #expect(!run.stderr.contains("Mac-only"), "mailroom \(verb) still refused as Mac-only") + for verb in ["post", "inbox", "sync", "read", "list", "watch"] { + let run = try runShim(["mail", verb]) + #expect(!run.stderr.contains("Mac-only"), "mail \(verb) still refused as Mac-only") + } + // The pre-rename spellings still reach the same parser: loops relaunched with a + // briefing written before the rename type `artifactory sync`, and must not be told + // the verb is Mac-only. + for verb in ["mailroom", "artifactory"] { + let run = try runShim([verb, "sync"]) + #expect(!run.stderr.contains("Mac-only"), "\(verb) sync still refused as Mac-only") + #expect(!run.stderr.contains("unknown"), "\(verb) sync no longer parses") } // A subcommand that genuinely does not exist says so as the Swift CLI does. let bogus = try runShim(["mailroom", "resolve", Self.project]) #expect(bogus.status == 1) - #expect(bogus.stderr.contains("unknown command: mailroom resolve")) + #expect(bogus.stderr.contains("unknown command: mail resolve")) // And a mistyped flag is refused rather than silently ignored. let mistyped = try runShim(["mailroom", "list", Self.project, "--serach", "red"]) #expect(mistyped.status == 1) @@ -646,8 +654,8 @@ extension RemoteCLIShimTests { func theHelpTextTeachesEveryVerbTheBriefingDoes() throws { let help = try runShim(["mailroom", "--help"]) #expect(help.status == 0) - for verb in ["post", "sync", "read", "list", "watch"] { - #expect(help.stdout.contains("graphcode mailroom \(verb) ")) + for verb in ["post", "inbox", "read", "list", "watch"] { + #expect(help.stdout.contains("graphcode mail \(verb) ")) } // The shim's own honesty rule: nothing it implements may sit on the Mac-only list. #expect(!help.stdout.contains("(update, pilot, arm, edge, usage, mailroom)")) From 939e2e673b840ddc361f237d1931ab20f8864996 Mon Sep 17 00:00:00 2001 From: scgopi Date: Fri, 4 Sep 2026 21:34:26 -0700 Subject: [PATCH 6/6] Finish the vocabulary in the surfaces a user actually reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rename audit turned up copy the blanket pass could not catch, because none of it contains the word "Artifactory" — it was stale in the other direction: - The MAILROOM help section still taught `sync`, which is now the alias and not the verb. A help page naming a verb the help page above it does not list is worse than either spelling on its own. - `mail post` advertised `` in the CLI help, the briefing, the shim's help, and the end-of-run ask. The argument is a notice. - "the board is empty" became "the room is empty" on both renderers, and RemoteCLIShimTests caught the shim's `` before it shipped — the byte-equality rule earning its keep. - The mirrored-traffic paragraph now calls them letters. Left alone deliberately: "board" as an informal noun in comments and in prose about the notices half. A mailroom has a board on its wall, and rewriting 114 occurrences would be churn with no reader on the other end. 1584 tests / 163 suites pass; swiftlint 0 errors, swift-format --strict clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AtVtkzZNE5fLPeHVqM7A3K --- .../Sources/CLI/GraphcodeCommand.swift | 26 +++++++++---------- .../Sources/Domain/SessionBriefing.swift | 2 +- .../Sources/Sessions/MessageBus.swift | 6 ++--- .../Sources/Sessions/RemoteGraphAccess.swift | 8 +++--- graphcode/Tests/MailroomCommandTests.swift | 2 +- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index afa3be41..07c9fc86 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -101,7 +101,7 @@ public enum GraphcodeCommand: Equatable, Sendable { graphcode node pilot dry-run a composite graphcode node arm arm it (needs a pilot first) graphcode edge create [--kind ] [--condition ] - graphcode mail post [--topic ] + graphcode mail post [--topic ] post a notice to the whole graph, for whoever comes next graphcode mail inbox [--headlines] [--full] [--mark] [--json] read your unread mail and mark the room read. A large @@ -229,17 +229,17 @@ public enum GraphcodeCommand: Equatable, Sendable { MAILROOM The shared, unaddressed board: `node send` reaches one peer you already know; - a Mailroom post is a note for whoever comes next, discoverable by loops that - did not exist when it was written. Run from inside a loop, posts are attributed - to that loop (`ZMX_SESSION`, the same mechanism as `node send`); from a human's - shell they read as from "a human". `sync` and `watch` need that loop identity — - the read cursor and the subscription belong to a loop — so a human reads the - board with `list`. A post is a note to a peer, not a transcript: 1 KB bound, - and `--topic ` groups a thread (a watcher of a topic only hears matching - posts; watched posts are delivered like a --follow-up message). Note that the - mirrored `direct` and `handoff` records never ring watchers — they are the - board's record of traffic that already had its own delivery, so a watch on - those topics alone stays silent. + a notice is addressed to nobody, left for whoever comes next and found by loops + that did not exist when it was written. Run from inside a loop, posts are + attributed to that loop (`ZMX_SESSION`, the same mechanism as `node send`); from + a human's shell they read as from "a human". `inbox` and `watch` need that loop + identity — the read cursor and the subscription belong to a loop — so a human + reads the room with `list`. A notice is a note to a peer, not a transcript: 1 KB + bound, and `--topic ` groups a thread (a watcher of a topic only hears + matching posts; watched posts are delivered like a --follow-up message). The + letters copied from `direct` and `handoff` traffic never ring watchers — they + are the room's copy of something that already had its own delivery, so a watch + on those topics alone stays silent. EXIT CODES 0 done 1 bad usage, or graphcoded refused the command @@ -883,7 +883,7 @@ extension GraphcodeCommand { : "no unread posts match '\(search)'" } return readerID == nil - ? "the board is empty — post one: graphcode mail post " + ? "the room is empty — post one: graphcode mail post " : "no unread posts" } // `sync` asks to be triaged; `--headlines` and `--full` are the two ways to say diff --git a/GraphcodeKit/Sources/Domain/SessionBriefing.swift b/GraphcodeKit/Sources/Domain/SessionBriefing.swift index 94d3a1dc..a7159da7 100644 --- a/GraphcodeKit/Sources/Domain/SessionBriefing.swift +++ b/GraphcodeKit/Sources/Domain/SessionBriefing.swift @@ -98,7 +98,7 @@ public enum SessionBriefing { ```sh graphcode mail inbox \(projectPath) # read what you have not seen, mark it read graphcode mail read \(projectPath) # one post in full - graphcode mail post \(projectPath) [--topic ] # leave something behind + graphcode mail post \(projectPath) [--topic ] # leave something behind graphcode mail list \(projectPath) # read-only peek, cursor untouched graphcode mail watch \(projectPath) [--topic ] # ring me when new mail lands ``` diff --git a/GraphcodeKit/Sources/Sessions/MessageBus.swift b/GraphcodeKit/Sources/Sessions/MessageBus.swift index e1d92293..40c892e3 100644 --- a/GraphcodeKit/Sources/Sessions/MessageBus.swift +++ b/GraphcodeKit/Sources/Sessions/MessageBus.swift @@ -101,9 +101,9 @@ public enum MessageBus { /// a successor would otherwise pay for twice. private static func mailroomPostBody(projectPath: String) -> String { "Before you finish: if you learned something a peer or a successor should not have " - + "to rediscover — a dead end, a decision, a claim you staked — leave it on the " - + "board with: graphcode mail post \(projectPath) [--topic ] . " - + "One note, not a transcript. If there is nothing worth a peer's time, skip this." + + "to rediscover — a dead end, a decision, a claim you staked — post it as a " + + "notice: graphcode mail post \(projectPath) [--topic ] . " + + "One notice, not a transcript. If there is nothing worth a peer's time, skip this." } /// The words for a session whose loop has just resolved, or `nil` when it is owed diff --git a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift index 7e128530..7d6419d8 100644 --- a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift +++ b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift @@ -174,7 +174,7 @@ public enum RemoteGraphAccess { graphcode node delete irreversible; stop is reversible graphcode node send graphcode node memo - graphcode mail post [--topic ] + graphcode mail post [--topic ] graphcode mail inbox [--headlines] [--full] [--mark] [--json] graphcode mail read graphcode mail list [--search ] [--json] @@ -482,8 +482,8 @@ public enum RemoteGraphAccess { return "no unread posts match '%s'" % search if reader is not None: return "no unread posts" - return ("the board is empty %s post one: graphcode mail post " - " " % (EM_DASH, ELLIPSIS)) + 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" header = "%s %s: %d post%s" % (project.get("name", "?"), label, len(posts), @@ -851,7 +851,7 @@ public enum RemoteGraphAccess { if latest > 0: print("marked read up to #%d" % latest) else: - print("marked read %s the board is empty" % EM_DASH) + print("marked read %s the room is empty" % EM_DASH) else: print(render_board(graph, reader=reader, headlines=headlines, auto_triage=not headlines and not full)) diff --git a/graphcode/Tests/MailroomCommandTests.swift b/graphcode/Tests/MailroomCommandTests.swift index 5b71f950..c12a1650 100644 --- a/graphcode/Tests/MailroomCommandTests.swift +++ b/graphcode/Tests/MailroomCommandTests.swift @@ -147,7 +147,7 @@ struct MailroomCommandTests { @Test func emptyBoardAndNothingUnreadSaySo() { var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) - #expect(GraphcodeCommand.renderMailroom(graph).contains("the board is empty")) + #expect(GraphcodeCommand.renderMailroom(graph).contains("the room is empty")) var reader = LoopNode(title: "Reader", loopType: .turnBased) reader.lastMailroomRead = 3