diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index 66f32d0f..07c9fc86 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -1,5 +1,5 @@ -import ArtifactoryKit import Foundation +import MailroomKit /// Argument parsing and output formatting for the `graphcode` CLI /// (docs/03-architecture.md#cli-graphcode). @@ -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 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. - 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,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 artifactory post [--topic ] - leave a note on the shared board for whoever comes next - graphcode artifactory 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 artifactory read + graphcode mail read one post in full — the deep-read half of --headlines - graphcode artifactory 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 artifactory 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 @@ -227,19 +227,19 @@ 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 - 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 @@ -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 mail inbox check what other loops left for you before starting a pass - graphcode artifactory 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,8 +402,10 @@ public enum GraphcodeCommand: Equatable, Sendable { throw ParseError.unknownCommand("node \(verb)") } - case "artifactory": - return try parseArtifactory(&arguments) + // `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": let verb = try take(&arguments, name: "edge subcommand") @@ -768,7 +770,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 +778,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 +810,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 +849,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. `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 `artifactory 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. - 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 +883,20 @@ extension GraphcodeCommand { : "no unread posts match '\(search)'" } return readerID == nil - ? "the board is empty — post one: graphcode artifactory 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 // 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 mail read \(graph.project.path) " } var lines = [header] for post in posts { @@ -903,25 +905,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 +932,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 +943,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`, `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: 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 +989,10 @@ extension GraphcodeCommand { return String(full.prefix(budget)) + "…" } - /// `artifactory 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.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 +1096,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 mail 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 +1119,13 @@ 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": + // `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 .artifactorySync( + return .mailroomInbox( projectPath: path, headlines: flags["headlines"] != nil, mark: flags["mark"] != nil, json: flags["json"] != nil, full: flags["full"] != nil) @@ -1134,21 +1137,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..8cab0dfc 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,9 @@ 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) + ?? 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 0a5ed908..adaba123 100644 --- a/GraphcodeKit/Sources/Domain/LoopGraph.swift +++ b/GraphcodeKit/Sources/Domain/LoopGraph.swift @@ -1,6 +1,6 @@ -import ArtifactoryKit 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. @@ -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.maxNotices`, `Mailroom.maxLetters`). Kept on the graph rather than in a /// side store so it inherits for free everything graph state already has: one /// writer (the daemon), atomic persistence beside the graph file, a snapshot in /// every `.graphChanged` (which is how the CLI reads it — no second read path), and /// the global graph at `graphcode://global` becoming a cross-project board without /// a line of extra code. Empty for anyone who never touches the board; graphs saved /// before the field existed decode with it empty. - 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,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) ?? [] - artifactory = try container.decodeIfPresent([ArtifactoryPost].self, forKey: .artifactory) ?? [] + mailroom = + try container.decodeIfPresent([MailroomPost].self, forKey: .mailroom) + ?? decoder.legacyMailroomValue([MailroomPost].self, "artifactory") ?? [] } public func encode(to encoder: Encoder) throws { @@ -280,6 +282,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..42804355 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -1,5 +1,5 @@ -import ArtifactoryKit 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. @@ -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 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 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 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. - 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,14 @@ 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) + ?? 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 3b9755d9..a7159da7 100644 --- a/GraphcodeKit/Sources/Domain/SessionBriefing.swift +++ b/GraphcodeKit/Sources/Domain/SessionBriefing.swift @@ -77,46 +77,45 @@ 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 — the graph's mail, and notices 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 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") — - 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 """ @@ -191,7 +190,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..b606cadc 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -1,5 +1,5 @@ -import ArtifactoryKit 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 @@ -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 .mailroomInbox(let from): + mailroomInbox(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 `mail inbox`. + 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("mail 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 " + "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("artifactory 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 <= ArtifactoryPost.maxTopicBytes else { + guard trimmedTopic?.utf8.count ?? 0 <= MailroomPost.maxTopicBytes else { announceError( - "artifactory post refused: topic over \(ArtifactoryPost.maxTopicBytes) bytes") + "mail 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,89 +1641,89 @@ 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 mail inbox \(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 /// 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 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, - author: sender, topic: topic, body: body, kind: .record) - graph.artifactory = Artifactory.pruned(graph.artifactory + [post]) + let post = MailroomPost( + id: Mailroom.nextID(after: graph.mailroom), at: Date(), authorID: senderID, + author: sender, topic: topic, body: body, kind: .letter) + 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 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 artifactorySync(from readerID: UUID?) { - guard artifactoryIsOn() else { + private func mailroomInbox(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 " + "mail inbox needs a loop identity — run it from a loop's session " + "($ZMX_SESSION); a human reading the board needs no cursor") return } - // Never moves backward: ids only grow (`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 " + "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,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("mail 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..7a260f4d 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 + /// 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 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 + /// 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 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 mailroomInbox(from: UUID?) + /// Subscribe (`on: true`) or unsubscribe (`on: false`) the calling loop to Mailroom + /// posts — `graphcode mail watch`. A watched post is delivered the way a /// `--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..40c892e3 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,22 +99,22 @@ 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 ] . " - + "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 /// 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..d6d6a8ba 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 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 6928466d..7d6419d8 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 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. @@ -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 " - " " % (EM_DASH, ELLIPSIS)) - triaged = auto_triage and artifactory_needs_triage(posts) - label = "artifactory" if reader is None else "artifactory, unread" + 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), "" 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 mail 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 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")), @@ -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 `mail post` needs to name the # sequence number the note landed at. daemon = Daemon() project = resolve_project(daemon, project) @@ -767,8 +767,9 @@ public enum RemoteGraphAccess { return bool(arguments) and arguments[0] in HELP_FLAGS - ARTIFACTORY_FLAGS = { + MAILROOM_FLAGS = { "post": ("topic",), + "inbox": ("headlines", "mark", "json", "full"), "sync": ("headlines", "mark", "json", "full"), "read": (), "list": ("search", "json"), @@ -776,7 +777,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 +788,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 mail subcommand") subverb = arguments.pop(0) if wants_help(arguments): print(HELP) @@ -803,10 +804,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: mail %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 +822,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": + if subverb in ("inbox", "sync"): reader = self_node_id() if not reader: - fail("artifactory 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 " - "artifactory 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, {"artifactorySync": {"from": reader}})) + daemon.send(graph_command(project, {"mailroomInbox": {"from": reader}})) key, value = daemon.wait_for(["graphChanged", "errorOccurred"]) if key == "errorOccurred": fail(value["_0"]) @@ -845,12 +846,12 @@ 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) 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)) @@ -859,15 +860,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 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("artifactory 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 @@ -895,7 +896,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 +922,8 @@ public enum RemoteGraphAccess { fail("missing project-path") run_and_print(arguments[0]) return - if verb == "artifactory": - artifactory(arguments) + if verb in ("mail", "mailroom", "artifactory"): + 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 72% rename from ArtifactoryKit/Sources/Artifactory.swift rename to MailroomKit/Sources/Mailroom.swift index 1a8eccac..e848fa5b 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 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 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,10 +22,22 @@ 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`). - 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 ArtifactoryPost: 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 ArtifactoryPost: 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. @@ -103,8 +115,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 +127,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,23 +140,27 @@ 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 - /// 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 +public enum Mailroom { + /// 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 /// 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 } @@ -154,34 +170,34 @@ public enum Artifactory { /// 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 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] { - let notes = posts.filter { $0.kind == .note } - let records = posts.filter { $0.kind == .record } - guard notes.count > maxNotes || records.count > maxRecords else { return posts } + public static func pruned(_ posts: [MailroomPost]) -> [MailroomPost] { + 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/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..af3bc3ac 100644 --- a/docs/ramps.json +++ b/docs/ramps.json @@ -4,6 +4,10 @@ "beta": 100, "stable": 100 }, + "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 e1ed7381..9342b54f 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 .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 `artifactory 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( - "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`") + "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: .artifactorySync(from: reader))) + .graphCommand(projectPath: projectPath, command: .mailroomInbox(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 mail 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 " + "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) 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..0c87c6bc 100644 --- a/graphcode/Sources/Clients/FeatureRamps.swift +++ b/graphcode/Sources/Clients/FeatureRamps.swift @@ -23,7 +23,17 @@ enum FeatureRamps { enum Feature: String { case codespaces - case artifactory + 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 @@ -32,7 +42,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] } } } @@ -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/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..4d35a707 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 `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 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.notices(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..11826f29 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 @@ -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. - static func artifactoryHeightCap(railHeight: CGFloat) -> CGFloat { - min(ArtifactorySection.maxScrollHeight, max(160, railHeight * 0.4)) + /// 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(minMailroomHeight, railHeight * railShare)) } var body: some View { @@ -214,7 +224,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 +234,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 +276,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 83% rename from graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift rename to graphcode/Sources/Features/LoopWorkspace/MailroomSection.swift index 9d1032fa..01418a00 100644 --- a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift +++ b/graphcode/Sources/Features/LoopWorkspace/MailroomSection.swift @@ -1,9 +1,9 @@ -import ArtifactoryKit import GraphcodeKit +import MailroomKit 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 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) -> [ArtifactoryPost] { - graph.artifactory.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.seenArtifactoryPostID`, 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 { - Artifactory.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 } } -/// 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 +/// 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. -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,40 +51,41 @@ 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. 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.artifactoryHeightCap`); 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 = "" @FocusState private var draftFocused: Bool - private var notes: [ArtifactoryPost] { ArtifactoryPresentation.notes(in: graph) } - private var records: [ArtifactoryPost] { ArtifactoryPresentation.records(in: graph) } + private var notices: [MailroomPost] { MailroomPresentation.notices(in: graph) } + private var letters: [MailroomPost] { MailroomPresentation.letters(in: graph) } private var unread: Int { - ArtifactoryPresentation.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 /// 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 ArtifactorySection: 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,14 +140,14 @@ struct ArtifactorySection: View { } private var unreadIDs: Set { - Set(notes.suffix(unread).map(\.id)) + Set(notices.suffix(unread).map(\.id)) } // MARK: - Header 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)) @@ -178,9 +179,9 @@ struct ArtifactorySection: 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 ArtifactorySection: 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(ArtifactoryPost.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)) } @@ -279,7 +280,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 +295,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 +323,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 +399,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..abe5d396 100644 --- a/graphcode/Sources/Features/Settings/SettingsView.swift +++ b/graphcode/Sources/Features/Settings/SettingsView.swift @@ -177,17 +177,19 @@ 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 " - + "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/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 71% rename from graphcode/Tests/ArtifactoryBudgetTests.swift rename to graphcode/Tests/MailroomBudgetTests.swift index e2f6caac..4405ce2b 100644 --- a/graphcode/Tests/ArtifactoryBudgetTests.swift +++ b/graphcode/Tests/MailroomBudgetTests.swift @@ -1,14 +1,14 @@ -import ArtifactoryKit 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 /// 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,16 +43,16 @@ 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.maxLetters * 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 == .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 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.maxNotices + 10) { + await store.handle(.mailroomPost(text: "note \(index)", topic: nil, from: ids[0])) } - let board = await store.graph.artifactory - #expect(board.filter { $0.kind == .record }.count == 1) - #expect(board.filter { $0.kind == .note }.count == Artifactory.maxNotes) + let board = await store.graph.mailroom + #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 == Artifactory.maxNotes + 11) + #expect(board.last?.id == Mailroom.maxNotices + 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.maxLetters + 4) { posts.append( - ArtifactoryPost( + MailroomPost( id: index, at: base, authorID: nil, author: "a human", topic: nil, - body: "r\(index)", kind: .record)) + body: "r\(index)", kind: .letter)) posts.append( - ArtifactoryPost( + 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 = 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 == .letter }.count == Mailroom.maxLetters) } /// A board written before records had a kind decodes as all notes — everything on it @@ -100,8 +100,8 @@ struct ArtifactoryBudgetTests { let json = """ {"id":3,"at":747000000,"author":"Author","body":"hello"} """ - let post = try JSONDecoder().decode(ArtifactoryPost.self, from: Data(json.utf8)) - #expect(post.kind == .note) + let post = try JSONDecoder().decode(MailroomPost.self, from: Data(json.utf8)) + #expect(post.kind == .notice) #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("mail 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( + == .mailroomInbox( 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( + == .mailroomInbox( 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 mail 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 mail 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("mail 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..c12a1650 100644 --- a/graphcode/Tests/ArtifactoryCommandTests.swift +++ b/graphcode/Tests/MailroomCommandTests.swift @@ -1,14 +1,14 @@ -import ArtifactoryKit import Foundation import GraphcodeKit +import MailroomKit 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"]) + == .mailroomInbox( 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 room 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 ["mail post", "mail inbox", "mail list", "mail 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( + == .mailroomInbox( 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( + == .mailroomInbox( 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/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/ArtifactoryRailShareTests.swift b/graphcode/Tests/MailroomRailShareTests.swift similarity index 59% rename from graphcode/Tests/ArtifactoryRailShareTests.swift rename to graphcode/Tests/MailroomRailShareTests.swift index 2e5bb8be..63f8c5c3 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) - // 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) + // 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.artifactoryHeightCap(railHeight: 300) == 160) - #expect(LoopWorkspaceRail.artifactoryHeightCap(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 @@ -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 72% rename from graphcode/Tests/ArtifactorySinceYouLookedTests.swift rename to graphcode/Tests/MailroomSinceYouLookedTests.swift index 428e544a..0f4a85ed 100644 --- a/graphcode/Tests/ArtifactorySinceYouLookedTests.swift +++ b/graphcode/Tests/MailroomSinceYouLookedTests.swift @@ -1,15 +1,15 @@ -import ArtifactoryKit import ComposableArchitecture import Foundation import GraphcodeKit +import MailroomKit 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.unreadNoticeCount(graph: graph, seenPostID: nil) == 3) + #expect(MailroomPresentation.unreadNoticeCount(graph: graph, seenPostID: 2) == 1) + #expect(MailroomPresentation.unreadNoticeCount(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)) + body: "@Worker: hi", kind: .letter)) 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..0b39f853 100644 --- a/graphcode/Tests/ArtifactoryTests.swift +++ b/graphcode/Tests/MailroomTests.swift @@ -1,14 +1,14 @@ -import ArtifactoryKit import ComposableArchitecture import Foundation import GraphcodeKit +import MailroomKit 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.maxNotices + 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.maxNotices) + #expect(graph.mailroom.first?.body == "post 5") + #expect(graph.mailroom.last?.id == Mailroom.maxNotices + 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(.mailroomInbox(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(.mailroomInbox(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(.mailroomInbox(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 mail inbox")) } @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,33 @@ 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 — 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 // line where the section would have gone. #expect(off?.contains("one-off.\n\n## Remembering across passes") == true) } @@ -495,7 +497,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 +505,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..4dc44045 100644 --- a/graphcode/Tests/RemoteCLIShimTests.swift +++ b/graphcode/Tests/RemoteCLIShimTests.swift @@ -1,5 +1,5 @@ -import ArtifactoryKit import Foundation +import MailroomKit import Testing @testable import GraphcodeKit @@ -230,24 +230,24 @@ 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 // 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) } @@ -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: .mailroomInbox(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,34 +596,42 @@ 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("mail inbox needs a loop identity")) + #expect(sync.stderr.contains("graphcode mail 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("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. #expect(sync.commands.isEmpty) #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") + 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(["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: mail 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 +639,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 mail 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 +652,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) ")) + 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, 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)") } }