From e4fc726da18a57737d085a7d5cce402591b99c75 Mon Sep 17 00:00:00 2001 From: scgopi Date: Mon, 31 Aug 2026 09:39:51 -0700 Subject: [PATCH 01/10] Mailboard: a shared, unaddressed board for loops, beta-ramped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node send and edges are addressed — a loop must already know a peer's id. The Mailboard is the ambient counterpart: one append-only board per graph (global graph included) that any loop can post to for whoever comes next — a decision made, a dead end hit, a claim staked — and read back with one command, with no wiring and no ids. Posts survive their authors; a loop created after the writer is gone still finds the note. - MailboardKit module: post/watch model, caps (1 KiB body, 200 posts), unread arithmetic shared by every surface - Board rides LoopGraph: one writer, persisted beside the graph, snapshot in every .graphChanged (the CLI's read path), graphcode://global free - GraphStore: mailboard post/sync/watch commands; watcher wakes ride deliverAdHocMessage's follow-up semantics (typed when idle, staged to memory otherwise); fresh-read beta gate with refusal said out loud - Wake digest gains a check-the-board reminder; the generated briefing teaches the verbs only while the feature is on - Ramp: FeatureRamps.mailboard ships beta:100/stable:0; the app resolves it into settings mailboardEnabled, explicit user choice outranks the ramp, Settings toggle offered only while the ramp has it on - CLI: graphcode mailboard post|sync|list|watch, attributed via ZMX_SESSION exactly like node send Signed-off-by: scgopi --- .../Sources/CLI/GraphcodeCommand.swift | 128 +++++++++++ .../Sources/Domain/GraphcodeSettings.swift | 22 ++ GraphcodeKit/Sources/Domain/LoopGraph.swift | 16 +- GraphcodeKit/Sources/Domain/LoopNode.swift | 23 ++ .../Sources/Domain/SessionBriefing.swift | 29 ++- GraphcodeKit/Sources/GraphStore.swift | 161 ++++++++++++++ GraphcodeKit/Sources/IPC/DaemonProtocol.swift | 21 ++ GraphcodeKit/Sources/ProjectRegistry.swift | 5 +- .../Sources/Sessions/NodeMemory.swift | 14 +- .../Sources/Sessions/ZmxSessionLauncher.swift | 5 +- MailboardKit/Sources/Mailboard.swift | 91 ++++++++ Project.swift | 16 ++ graphcode-cli/Sources/main.swift | 99 +++++++++ graphcode/Sources/Clients/FeatureRamps.swift | 2 + .../Features/Settings/SettingsModel.swift | 65 +++++- .../Features/Settings/SettingsView.swift | 17 ++ graphcode/Tests/FeatureRampsTests.swift | 24 ++ graphcode/Tests/MailboardCommandTests.swift | 182 +++++++++++++++ graphcode/Tests/MailboardTests.swift | 210 ++++++++++++++++++ graphcode/Tests/SettingsMailboardTests.swift | 70 ++++++ 20 files changed, 1194 insertions(+), 6 deletions(-) create mode 100644 MailboardKit/Sources/Mailboard.swift create mode 100644 graphcode/Tests/MailboardCommandTests.swift create mode 100644 graphcode/Tests/MailboardTests.swift create mode 100644 graphcode/Tests/SettingsMailboardTests.swift diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index 969e08f7..b1809c2e 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -1,4 +1,5 @@ import Foundation +import MailboardKit /// Argument parsing and output formatting for the `graphcode` CLI /// (docs/03-architecture.md#cli-graphcode). @@ -43,6 +44,18 @@ 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 Mailboard 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 mailboardPost(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. + case mailboardSync(projectPath: String) + /// The whole board, read-only: no command reaches the daemon, no cursor moves. + case mailboardList(projectPath: String) + /// Subscribe (`on: true`, `--topic` filters) or unsubscribe (`--off`) the calling + /// loop; like `sync`, the subscription belongs to a loop, not a shell. + case mailboardWatch(projectPath: String, on: Bool, topic: String?) public enum ParseError: Error, Equatable { case unknownCommand(String) @@ -71,6 +84,15 @@ 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 mailboard post [--topic ] + leave a note on the shared board for whoever comes next + graphcode mailboard sync + read your unread posts and mark the board read + graphcode mailboard list + the whole board, read-only — no cursor moves + graphcode mailboard watch [--topic ] [--off] + have matching posts typed into this loop's session as they + land; --off stops watching graphcode usage graphcode reap [--dry-run] recover suspected orphaned zmx sessions when PTYs cannot be allocated or deleted loops leave sessions behind @@ -166,6 +188,18 @@ 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 + MAILBOARD + The shared, unaddressed board: `node send` reaches one peer you already know; + a Mailboard 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). + + EXIT CODES 0 done 1 bad usage, or graphcoded refused the command @@ -181,6 +215,10 @@ public enum GraphcodeCommand: Equatable, Sendable { graphcode status graphcode node send --follow-up stage work without interrupting an active turn + graphcode mailboard sync + check what other loops left for you before starting a pass + graphcode mailboard 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 pilot before arming a proactive routine @@ -313,6 +351,9 @@ public enum GraphcodeCommand: Equatable, Sendable { throw ParseError.unknownCommand("node \(verb)") } + case "mailboard": + return try parseMailboard(&arguments) + case "edge": let verb = try take(&arguments, name: "edge subcommand") guard verb == "create" else { throw ParseError.unknownCommand("edge \(verb)") } @@ -729,6 +770,50 @@ extension GraphcodeCommand { return projects.map { "\($0.name) \($0.path)" }.joined(separator: "\n") } + /// The board for a terminal. `mailboard list` prints the whole thing (`reader` + /// nil); `mailboard sync` passes the reading loop's id and prints only what its + /// cursor has not covered — the subtraction is `Mailboard.unread`, the arithmetic + /// the daemon's cursor contract rests on, so the CLI's "unread" and the store's can + /// never disagree. + public static func renderMailboard( + _ graph: LoopGraph, unreadFor readerID: UUID? = nil + ) -> String { + let posts: [MailboardPost] + if let readerID { + posts = Mailboard.unread( + in: graph.mailboard, since: graph.nodes[id: readerID]?.lastMailboardRead) + } else { + posts = graph.mailboard + } + guard !posts.isEmpty else { + return readerID == nil + ? "the board is empty — post one: graphcode mailboard post " + : "no unread posts" + } + let label = readerID == nil ? "mailboard" : "mailboard, unread" + var lines = [ + "\(graph.project.name) \(label): \(posts.count) post\(posts.count == 1 ? "" : "s")" + ] + for post in posts { lines.append(" \(render(post))") } + return lines.joined(separator: "\n") + } + + /// 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: MailboardPost) -> String { + let topic = post.topic.map { " (\($0))" } ?? "" + let stamp = post.at.formatted(date: .abbreviated, time: .shortened) + return "#\(post.id)\(topic) from \(post.author) at \(stamp) — \(post.body)" + } + + /// `mailboard 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.mailboard.last else { return "posted" } + let topic = post.topic.map { " (\($0))" } ?? "" + return "posted #\(post.id)\(topic)" + } + public static func describe(_ error: ParseError) -> String { switch error { case .unknownCommand(let name): return "unknown command: \(name)" @@ -782,4 +867,47 @@ extension GraphcodeCommand { } return .importNodes(projectPath: projectPath, fromZip: zipPath, asChildOf: asChildOf) } + + /// The `mailboard` verbs' parsing, split from `parseVerb` the way export/import + /// were. The note is joined argv words — the `node send`/`node memo` bargain, so + /// `graphcode mailboard post --topic claims issue #12 is mine` needs no + /// quoting gymnastics — with `--topic ` riding along in either position. + fileprivate static func parseMailboard( + _ arguments: inout [String] + ) throws -> GraphcodeCommand { + let verb = try take(&arguments, name: "mailboard subcommand") + let path = try take(&arguments, name: "project-path") + if arguments.contains(where: isHelpFlag) { throw HelpRequested() } + switch verb { + case "post": + try validateFlags(arguments, allowed: ["topic"]) + let flags = parseFlags(arguments) + // Strip the flag pair; a trailing `--topic` with no value goes too — it was + // meant as the flag, never as the note's text, and dropping it lets the empty + // note error say the real thing instead of echoing the flag back. + var words = arguments + if let index = words.firstIndex(of: "--topic") { + words.removeSubrange(index...min(index + 1, words.count - 1)) + } + let text = words.joined(separator: " ").trimmingCharacters(in: .whitespaces) + guard !text.isEmpty else { throw ParseError.missingArgument("note") } + return .mailboardPost(projectPath: path, topic: flags["topic"], text: text) + + case "sync": + try validateFlags(arguments, allowed: []) + return .mailboardSync(projectPath: path) + + case "list": + try validateFlags(arguments, allowed: []) + return .mailboardList(projectPath: path) + + case "watch": + try validateFlags(arguments, allowed: ["topic", "off"]) + let flags = parseFlags(arguments) + return .mailboardWatch(projectPath: path, on: flags["off"] == nil, topic: flags["topic"]) + + default: + throw ParseError.unknownCommand("mailboard \(verb)") + } + } } diff --git a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift index 05a4e061..ccef4f5b 100644 --- a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift +++ b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift @@ -375,6 +375,21 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { /// heartbeat loops immediately without restarting anything. public var daemonHeartbeatEnabled: Bool + /// Whether loops get the **Mailboard** — 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 Mailboard 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.mailboard` — 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 `mailboard` + /// 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 + /// `sharesLoops`' is. + public var mailboardEnabled: Bool + /// Whether `graphcoded` keeps the Mac awake while any loop is running /// (`AwakeAssertion`). Off by default and deliberately so: a background process that /// quietly stops a machine sleeping is a thing to opt into, not to inherit from an @@ -403,6 +418,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { summaryUsesModel: Bool = false, visualisesSummaries: Bool = false, daemonHeartbeatEnabled: Bool = false, + mailboardEnabled: Bool = false, keepsMacAwakeWhileLoopsRun: Bool = false, worktreePolicies: [String: WorktreeHygienePolicy] = [:] ) { @@ -419,6 +435,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { self.summaryUsesModel = summaryUsesModel self.visualisesSummaries = visualisesSummaries self.daemonHeartbeatEnabled = daemonHeartbeatEnabled + self.mailboardEnabled = mailboardEnabled self.keepsMacAwakeWhileLoopsRun = keepsMacAwakeWhileLoopsRun self.worktreePolicies = worktreePolicies } @@ -473,6 +490,11 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { try container.decodeIfPresent(Bool.self, forKey: .visualisesSummaries) ?? false daemonHeartbeatEnabled = try container.decodeIfPresent(Bool.self, forKey: .daemonHeartbeatEnabled) ?? false + // The daemon-side half of the beta ramp: the app writes the ramp-resolved value, + // so absent means "no app has spoken yet" and takes the off every non-app flow + // (CLI-only machines, hand-edited files) already had. + mailboardEnabled = + try container.decodeIfPresent(Bool.self, forKey: .mailboardEnabled) ?? false // 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 8b2a1bc9..28ec4352 100644 --- a/GraphcodeKit/Sources/Domain/LoopGraph.swift +++ b/GraphcodeKit/Sources/Domain/LoopGraph.swift @@ -1,5 +1,6 @@ import Foundation import IdentifiedCollections +import MailboardKit /// The unit `graphcoded`'s `GraphStore` owns and the graph canvas renders — see /// docs/02-graph-of-loops.md#loopgraph. @@ -19,6 +20,15 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { public var scope: LoopGraphScope public var nodes: IdentifiedArrayOf public var edges: IdentifiedArrayOf + /// The project's Mailboard — every post any loop has dropped onto the shared board, + /// oldest first, capped at `Mailboard.maxPosts`. 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 mailboard: [MailboardPost] = [] public var project: ProjectRef { get { scope.projectRef } @@ -234,7 +244,7 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { // MARK: - Coding private enum CodingKeys: String, CodingKey { - case id, nodes, edges + case id, nodes, edges, mailboard /// 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 @@ -249,6 +259,7 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { scope = LoopGraphScope(projectPath: ref.path, name: ref.name) nodes = try container.decodeIfPresent(IdentifiedArrayOf.self, forKey: .nodes) ?? [] edges = try container.decodeIfPresent(IdentifiedArrayOf.self, forKey: .edges) ?? [] + mailboard = try container.decodeIfPresent([MailboardPost].self, forKey: .mailboard) ?? [] } public func encode(to encoder: Encoder) throws { @@ -257,5 +268,8 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { try container.encode(project, forKey: .project) try container.encode(nodes, forKey: .nodes) 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 !mailboard.isEmpty { try container.encode(mailboard, forKey: .mailboard) } } } diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index bc45717e..b43c0bf6 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -1,4 +1,5 @@ import Foundation +import MailboardKit /// 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. @@ -134,6 +135,18 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// handoff, and custody has to be: stopping or deleting a parent takes its spawned /// descendants with it, while a drawn edge to a peer must never be caught in that. public let createdBy: UUID? + /// The newest Mailboard post this loop has read — `MailboardPost.id` of the last + /// post a `graphcode mailboard sync` showed it. `nil` has not synced yet and makes + /// every post unread; the cursor only moves through sync, so a loop that ignores + /// the board accrues nothing but a number, and a loop that died with unread mail + /// finds it still waiting at the next wake. + public var lastMailboardRead: Int? + /// This loop's standing subscription to its project's Mailboard — set and cleared + /// with `graphcode mailboard 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 mailboardWatch: MailboardWatch? public var state: LoopState public var createdAt: Date @@ -159,6 +172,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { presence: PresenceReading? = nil, metricHistory: [MetricSample] = [], createdBy: UUID? = nil, + lastMailboardRead: Int? = nil, + mailboardWatch: MailboardWatch? = nil, state: LoopState = .idle, createdAt: Date = Date() ) { @@ -183,6 +198,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { self.presence = presence self.metricHistory = metricHistory self.createdBy = createdBy + self.lastMailboardRead = lastMailboardRead + self.mailboardWatch = mailboardWatch self.state = state self.createdAt = createdAt } @@ -416,6 +433,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 lastMailboardRead, mailboardWatch case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly case summary, board, heartbeatIntervalSeconds } @@ -457,6 +475,11 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { metricHistory = try container.decodeIfPresent([MetricSample].self, forKey: .metricHistory) ?? [] createdBy = try container.decodeIfPresent(UUID.self, forKey: .createdBy) + // Absent from graphs saved before the Mailboard existed — every loop simply has + // not read anything yet, which is what `nil` says. + lastMailboardRead = try container.decodeIfPresent(Int.self, forKey: .lastMailboardRead) + mailboardWatch = try container.decodeIfPresent( + MailboardWatch.self, forKey: .mailboardWatch) state = try container.decodeIfPresent(LoopState.self, forKey: .state) ?? .idle createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() } diff --git a/GraphcodeKit/Sources/Domain/SessionBriefing.swift b/GraphcodeKit/Sources/Domain/SessionBriefing.swift index bba4d938..abb31bc9 100644 --- a/GraphcodeKit/Sources/Domain/SessionBriefing.swift +++ b/GraphcodeKit/Sources/Domain/SessionBriefing.swift @@ -77,6 +77,33 @@ 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 Mailboard'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. + let mailboardSection = + settings.mailboardEnabled + ? """ + ## The Mailboard — notes for whoever comes next + + `node send` reaches one peer you already know. The Mailboard 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 mailboard sync \(projectPath) # read what you have not seen, mark it read + graphcode mailboard post \(projectPath) [--topic ] # leave something behind + graphcode mailboard list \(projectPath) # read-only peek, cursor untouched + graphcode mailboard 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. + + """ + : "" return """ # You are a loop in a graphcode graph @@ -147,7 +174,7 @@ public enum SessionBriefing { 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. - + \(mailboardSection) ## Remembering across passes Loops that run in cycles get relaunched, and a relaunched session starts fresh. diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index d05a669a..4d08a5c3 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -1,4 +1,5 @@ import Foundation +import MailboardKit /// Owns the daemon's one `LoopGraph`, applies commands, automatically fires `.handoff` /// edges when a node resolves, keeps time-based nodes' sessions alive, and broadcasts @@ -79,6 +80,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 Mailboard 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 onMailboardEnabled: (@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 @@ -197,6 +203,7 @@ public actor GraphStore { @Sendable (LoopNode, LoopSummary, String?, String?) async -> SummaryBoard? )? = nil, onBoardsEnabled: (@Sendable () -> Bool)? = nil, + onMailboardEnabled: (@Sendable () -> Bool)? = nil, subGraphDepth: Int = 0 ) { self.graph = graph @@ -220,6 +227,7 @@ public actor GraphStore { self.onHeartbeatEnabled = onHeartbeatEnabled self.onComposeBoard = onComposeBoard self.onBoardsEnabled = onBoardsEnabled + self.onMailboardEnabled = onMailboardEnabled } private func recordMemory(_ nodeID: UUID, _ entry: String) { @@ -364,6 +372,15 @@ 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 .mailboardPost(let text, let topic, let from): + await mailboardPost(text: text, topic: topic, from: from) + + case .mailboardSync(let from): + mailboardSync(from: from) + + case .mailboardWatch(let on, let topic, let from): + mailboardWatch(on: on, topic: topic, from: from) + case .renameNode(let nodeID, let title): renameNode(nodeID, to: title) @@ -1201,6 +1218,150 @@ public actor GraphStore { recordMemory(nodeID, "playbook rolled back\(sender.map { " by \($0)" } ?? "")") } + // MARK: - Mailboard + + /// Whether the Mailboard 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 mailboardIsOn() -> Bool { onMailboardEnabled?() == 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 `mailboard sync`. + private func mailboardPost(text: String, topic: String?, from senderID: UUID?) async { + guard mailboardIsOn() else { + announceError( + "the Mailboard is off — enable Mailboard in Settings " + + "(mailboardEnabled in ~/.graphcode/settings.json)") + return + } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + announceError("mailboard post refused: empty note") + return + } + guard trimmed.utf8.count <= MailboardPost.maxBodyBytes else { + announceError( + "mailboard post refused: \(trimmed.utf8.count) bytes is over the " + + "\(MailboardPost.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 + } + let trimmedTopic = + topic.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + ?? Optional.none + if let trimmedTopic, trimmedTopic.isEmpty { + announceError("mailboard post refused: an empty topic is no topic — omit it") + return + } + guard trimmedTopic?.utf8.count ?? 0 <= MailboardPost.maxTopicBytes else { + announceError( + "mailboard post refused: topic over \(MailboardPost.maxTopicBytes) bytes") + return + } + let author = senderID.flatMap { graph.nodes[id: $0]?.title } ?? "a human" + let post = MailboardPost( + id: Mailboard.nextID(after: graph.mailboard), at: Date(), authorID: senderID, + author: author, topic: trimmedTopic, body: trimmed) + graph.mailboard = Mailboard.pruned(graph.mailboard + [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, "mailboard: posted #\(post.id)\(topicSuffix(post)) — \(post.body)") + } + await wakeMailboardWatchers(about: post) + } + + /// The mailbox's ring. Every watcher whose subscription matches hears the post the + /// way a `--follow-up` message arrives — typed into a live idle session, queued for + /// one mid-turn, staged to memory otherwise — by riding `deliverAdHocMessage`, so + /// 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 wakeMailboardWatchers(about post: MailboardPost) async { + for node in graph.nodes where node.id != post.authorID { + guard let watch = node.mailboardWatch, watch.matches(post.topic) else { continue } + let preview = + post.body.utf8.count > 140 + ? String(post.body.prefix(140)) + "…" : post.body + let nudge = + "mailboard — new post #\(post.id)\(topicSuffix(post)) from \(post.author): " + + "\(preview) — read it with: graphcode mailboard sync \(graph.project.path)" + await deliverAdHocMessage(to: node.id, text: nudge, from: nil, followUp: true) + } + } + + private func topicSuffix(_ post: MailboardPost) -> String { + post.topic.map { " (\($0))" } ?? "" + } + + /// Advances the reading loop's cursor to the newest post — the write half of + /// `graphcode mailboard sync`. Deliberately no memory record: sync is reading, + /// not learning, and a log line per read would turn the log into a metronome. + private func mailboardSync(from readerID: UUID?) { + guard mailboardIsOn() else { + announceError( + "the Mailboard is off — enable Mailboard in Settings " + + "(mailboardEnabled in ~/.graphcode/settings.json)") + return + } + guard let readerID, graph.nodes[id: readerID] != nil else { + announceError( + "mailboard sync needs a loop identity — run it from a loop's session " + + "($ZMX_SESSION); a human reading the board needs no cursor") + return + } + // Never moves backward: ids only grow (`Mailboard.nextID` is max-plus-one), so + // the max below only guards a board emptied by something other than pruning. + let latest = graph.mailboard.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]?.lastMailboardRead ?? 0 + graph.nodes[id: readerID]?.lastMailboardRead = 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 mailboardWatch(on: Bool, topic: String?, from watcherID: UUID?) { + guard mailboardIsOn() else { + announceError( + "the Mailboard is off — enable Mailboard in Settings " + + "(mailboardEnabled in ~/.graphcode/settings.json)") + return + } + guard let watcherID, graph.nodes[id: watcherID] != nil else { + announceError( + "mailboard 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 + } + if on { + let trimmed = + topic.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + ?? Optional.none + if let trimmed, trimmed.isEmpty { + announceError("mailboard watch refused: an empty topic is no topic — omit it") + return + } + graph.nodes[id: watcherID]?.mailboardWatch = MailboardWatch(topic: trimmed) + recordMemory( + watcherID, "mailboard: now watching \(trimmed.map { "'\($0)'" } ?? "all posts")") + } else { + guard graph.nodes[id: watcherID]?.mailboardWatch != nil else { + announceError("mailboard: \(graph.nodes[id: watcherID]?.title ?? "this loop") " + + "was not watching anything") + return + } + graph.nodes[id: watcherID]?.mailboardWatch = nil + recordMemory(watcherID, "mailboard: stopped watching") + } + } + // MARK: - Import /// Splices an export bundle's loops into this graph — the daemon half of diff --git a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift index 13278954..3999bf7e 100644 --- a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift +++ b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift @@ -123,6 +123,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 Mailboard — the shared, unaddressed board (`graphcode + /// mailboard 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 Mailboard off + /// (`mailboardEnabled` in `~/.graphcode/settings.json`) — a silent no-op would read, + /// to the loop that sent it, as a post nobody answered. + case mailboardPost(text: String, topic: String?, from: UUID?) + /// Mark every post on the Mailboard as read for the calling loop — `graphcode + /// mailboard sync`, the cursor half of reading. The CLI reads the board out of the + /// graph snapshot it already gets from `openProject`; this is the write that makes + /// "unread" mean something the *next* sync can subtract from. Requires a loop + /// identity: a human reading the board needs no cursor, since nothing downstream + /// tracks what they have seen. + case mailboardSync(from: UUID?) + /// Subscribe (`on: true`) or unsubscribe (`on: false`) the calling loop to Mailboard + /// posts — `graphcode mailboard 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 mailboardWatch(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 4b6f0f85..6479a6df 100644 --- a/GraphcodeKit/Sources/ProjectRegistry.swift +++ b/GraphcodeKit/Sources/ProjectRegistry.swift @@ -475,7 +475,10 @@ public actor ProjectRegistry { // meaningless without the reading that feeds it. Switching the rail off takes the // boards with it rather than leaving pictures of a run nothing is narrating. return settings.summarisesLoops && settings.visualisesSummaries - }) + }, + // 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. + onMailboardEnabled: { GraphcodeSettingsStore.load().mailboardEnabled }) 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/NodeMemory.swift b/GraphcodeKit/Sources/Sessions/NodeMemory.swift index 366e2e71..80ef4a8a 100644 --- a/GraphcodeKit/Sources/Sessions/NodeMemory.swift +++ b/GraphcodeKit/Sources/Sessions/NodeMemory.swift @@ -132,7 +132,8 @@ 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, baseURL: URL = SupportDirectory.url + projectPath: String, nodeID: UUID, mailboardEnabled: Bool = false, + baseURL: URL = SupportDirectory.url ) -> URL? { let all = entries(forProjectPath: projectPath, nodeID: nodeID, baseURL: baseURL) let playbook = playbook(forProjectPath: projectPath, nodeID: nodeID, baseURL: baseURL) @@ -153,6 +154,17 @@ public enum NodeMemory { "with: graphcode node memo ", "", ] + if mailboardEnabled { + // The reminder half of the Mailboard. 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 Mailboard is on: other loops may have left findings for you. " + + "Check at the start of a pass — graphcode mailboard sync — " + + "and post anything a peer or successor should not have to rediscover.") + lines.append("") + } if let playbook { // The playbook rides ahead of the history: it is the distilled *how*, where the // log is the raw *what happened*, and a session should read method before events. diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 33c4d6a7..3935eaff 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -634,7 +634,10 @@ public enum ZmxSessionLauncher { // reach it at its next wake. let wakeFile = projectPath != nil - ? NodeMemory.writeWakeDigest(projectPath: projectPath ?? "", nodeID: node.id) : nil + ? NodeMemory.writeWakeDigest( + projectPath: projectPath ?? "", nodeID: node.id, + mailboardEnabled: settings.mailboardEnabled) + : nil let wakePath: String? if let projectPath, remote != nil { wakePath = diff --git a/MailboardKit/Sources/Mailboard.swift b/MailboardKit/Sources/Mailboard.swift new file mode 100644 index 00000000..1edbea52 --- /dev/null +++ b/MailboardKit/Sources/Mailboard.swift @@ -0,0 +1,91 @@ +import Foundation + +/// One post on a Mailboard — 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 Mailboard 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, +/// deletion's siblings, and the daemon's own restarts. +/// +/// 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 MailboardPost: Codable, Equatable, Identifiable, Sendable { + /// Position in the board's sequence, 1-based. The unread cursor is this number, so + /// ids must only ever grow — they are assigned by `GraphStore` from the current + /// maximum, never from the post count, which pruning would shrink. + public let id: Int + public let at: Date + /// The posting loop's node id, when a loop posted it. `nil` from a human's shell + /// (`$ZMX_SESSION` absent), which is how a person talks to the whole graph at once. + public let authorID: UUID? + /// The author's loop title, or "a human" — what a reader sees; the id above is + /// what it uses to reply in person with `node send`. + public let author: String + /// An optional label for threads that keep themselves together — `auth`, `build`, + /// `issues`. A watcher subscribed to a topic only hears matching posts; `nil` posts + /// reach watchers of every topic except the ones that asked for another. + public let topic: String? + public let body: String + + public init( + id: Int, at: Date, authorID: UUID?, author: String, topic: String?, body: String + ) { + self.id = id + self.at = at + self.authorID = authorID + self.author = author + self.topic = topic + self.body = body + } + + /// The bound that keeps "check the board" cheap. A note that cannot fit in a + /// kilobyte is a document — put it in the repo and post the path. + public static let maxBodyBytes = 1024 + public static let maxTopicBytes = 64 +} + +/// A loop's standing subscription to its project's Mailboard — 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 MailboardWatch: Codable, Equatable, Sendable { + public var topic: String? + + public init(topic: String? = nil) { self.topic = topic } + + public func matches(_ topic: String?) -> Bool { self.topic == nil || self.topic == topic } +} + +/// 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 Mailboard { + /// How many posts a board keeps. The oldest fall off first: a Mailboard 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 maxPosts = 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: [MailboardPost]) -> Int { + (posts.map(\.id).max() ?? 0) + 1 + } + + /// The posts a loop with `lastRead` on its cursor has not seen yet. + public static func unread( + in posts: [MailboardPost], since lastRead: Int? + ) -> [MailboardPost] { + guard let lastRead else { return posts } + return posts.filter { $0.id > lastRead } + } + + /// A board that grew past `maxPosts`, oldest first gone. Applied by the store on + /// every post so no caller can forget. + public static func pruned(_ posts: [MailboardPost]) -> [MailboardPost] { + guard posts.count > maxPosts else { return posts } + return Array(posts.suffix(maxPosts)) + } +} diff --git a/Project.swift b/Project.swift index 214db890..ee963eb3 100644 --- a/Project.swift +++ b/Project.swift @@ -20,6 +20,21 @@ let project = Project( name: "graphcode", organizationName: "Graphcode", targets: [ + // `MailboardKit` — the Mailboard 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: "MailboardKit", + destinations: .macOS, + product: .staticFramework, + bundleId: "\(bundleIdPrefix).mailboard", + deploymentTargets: .macOS("15.0"), + buildableFolders: [ + "MailboardKit/Sources" + ] + ), .target( name: "GraphcodeKit", destinations: .macOS, @@ -30,6 +45,7 @@ let project = Project( "GraphcodeKit/Sources" ], dependencies: [ + .target(name: "MailboardKit"), .external(name: "IdentifiedCollections") ] ), diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index 4a6c98e2..539fb007 100644 --- a/graphcode-cli/Sources/main.swift +++ b/graphcode-cli/Sources/main.swift @@ -329,6 +329,105 @@ do { projectPath: projectPath, [.graphCommand(projectPath: projectPath, command: .armComposite(nodeID))]) + case .mailboardPost(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 + // the board. + let author = SurfaceRef.nodeID( + fromZmxSessionName: ProcessInfo.processInfo.environment["ZMX_SESSION"] ?? "") + try client.send(.openProject(path: projectPath)) + _ = try client.waitForEvent { if case .graphChanged = $0 { return true } else { return false } } + try client.send( + .graphCommand( + projectPath: projectPath, + command: .mailboardPost(text: text, topic: topic, from: author))) + let postVerdict = try client.waitForEvent { event in + switch event { + case .graphChanged, .errorOccurred: return true + default: return false + } + } + if case .errorOccurred(let message) = postVerdict { fail(message) } + if case .graphChanged(let graph) = postVerdict { + print(GraphcodeCommand.renderPosted(graph)) + } + + case .mailboardSync(let projectPath): + // Attributed like `node send` — and required, the one place a mailboard 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 `mailboard list`. + let reader = SurfaceRef.nodeID( + fromZmxSessionName: ProcessInfo.processInfo.environment["ZMX_SESSION"] ?? "") + guard let reader else { + fail( + "mailboard sync needs a loop identity — run it from inside a loop's session " + + "($ZMX_SESSION); a human reading the board wants `graphcode mailboard list`") + } + try client.send(.openProject(path: projectPath)) + let opened = try client.waitForEvent { + if case .graphChanged = $0 { return true } else { return false } + } + try client.send( + .graphCommand(projectPath: projectPath, command: .mailboardSync(from: reader))) + let syncVerdict = try client.waitForEvent { event in + switch event { + case .graphChanged, .errorOccurred: return true + default: return false + } + } + if case .errorOccurred(let message) = syncVerdict { fail(message) } + // Unread is computed from the snapshot `openProject` already delivered: sync only + // moves the cursor, so the posts it covers are exactly those above the cursor + // there — a post landing mid-command shows up at the next sync, as it should. + if case .graphChanged(let graph) = opened { + print(GraphcodeCommand.renderMailboard(graph, unreadFor: reader)) + } + + case .mailboardList(let projectPath): + // 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. + try client.send(.openProject(path: projectPath)) + let opened = try client.waitForEvent { + if case .graphChanged = $0 { return true } else { return false } + } + if case .graphChanged(let graph) = opened { + print(GraphcodeCommand.renderMailboard(graph)) + } + + case .mailboardWatch(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( + "mailboard 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 client.send(.openProject(path: projectPath)) + _ = try client.waitForEvent { if case .graphChanged = $0 { return true } else { return false } } + try client.send( + .graphCommand( + projectPath: projectPath, + command: .mailboardWatch(on: on, topic: topic, from: watcher))) + let watchVerdict = try client.waitForEvent { event in + switch event { + case .graphChanged, .errorOccurred: return true + default: return false + } + } + if case .errorOccurred(let message) = watchVerdict { fail(message) } + if on { + print( + topic.map { "watching '\($0)' — matching posts are typed in when the loop goes idle" } + ?? "watching all posts — they are typed in when the loop goes idle") + } else { + print("stopped watching") + } + case .reap: break // handled before the daemon dial above diff --git a/graphcode/Sources/Clients/FeatureRamps.swift b/graphcode/Sources/Clients/FeatureRamps.swift index db162099..c9de713e 100644 --- a/graphcode/Sources/Clients/FeatureRamps.swift +++ b/graphcode/Sources/Clients/FeatureRamps.swift @@ -23,6 +23,7 @@ enum FeatureRamps { enum Feature: String { case codespaces + case mailboard /// 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 @@ -31,6 +32,7 @@ enum FeatureRamps { var defaultPercents: [String: Int] { switch self { case .codespaces: return ["beta": 100, "stable": 100] + case .mailboard: return ["beta": 100, "stable": 0] } } } diff --git a/graphcode/Sources/Features/Settings/SettingsModel.swift b/graphcode/Sources/Features/Settings/SettingsModel.swift index 8d89f769..21dcbb5a 100644 --- a/graphcode/Sources/Features/Settings/SettingsModel.swift +++ b/graphcode/Sources/Features/Settings/SettingsModel.swift @@ -16,6 +16,12 @@ import Observation final class SettingsModel { static let shared = SettingsModel() + /// The user's explicit Mailboard 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 mailboardChoiceDefaultsKey = "mailboardChoice" + var settings: GraphcodeSettings { didSet { guard settings != oldValue else { return } @@ -33,8 +39,41 @@ final class SettingsModel { } } + /// Whether the Settings window offers the Mailboard switch at all — the `mailboard` + /// ramp (`FeatureRamps`), read once at construction for the same reason as + /// `AppSidebarView.offersCodespaces`: a ramp change applies from the next launch. A + /// switch a stable install was never offered can't have recorded a choice, so an + /// install that has chosen keeps its switch even if the ramp later pulls back. + let showsMailboard: Bool + + /// The Mailboard as a switch, following `betaUpdates`' shape — but the daemon + /// enforces this one, so a flip writes `mailboardEnabled` into `settings` (which + /// saves the file the daemon reads) *and* records the explicit choice that then + /// outranks the ramp for good. + var mailboardEnabled: Bool { + didSet { + UserDefaults.standard.set(mailboardEnabled, forKey: Self.mailboardChoiceDefaultsKey) + settings.mailboardEnabled = mailboardEnabled + } + } + private init() { - settings = GraphcodeSettingsStore.load() + let loaded = GraphcodeSettingsStore.load() + let mailboard = Self.resolvesMailboard( + loaded: loaded.mailboardEnabled, + explicitChoice: + UserDefaults.standard.object(forKey: Self.mailboardChoiceDefaultsKey) as? Bool, + rampedOn: FeatureRamps.isEnabled(.mailboard)) + var booted = loaded + booted.mailboardEnabled = mailboard.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 mailboard.fileNeedsWrite { + GraphcodeSettingsStore.save(booted) + } + mailboardEnabled = mailboard.enabled + showsMailboard = mailboard.showsSwitch let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" betaUpdates = @@ -42,4 +81,28 @@ final class SettingsModel { for: version, override: UserDefaults.standard.string(forKey: "updateChannel")) == .beta } + + /// The Mailboard'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 — beta first, stable + /// only when `ramps.json` raises it — and that answer has to reach + /// `~/.graphcode/settings.json` when it differs, because the daemon enforces + /// `mailboardEnabled` out of the file and cannot see ramps or `UserDefaults`. A + /// recorded choice outranks the ramp from then on, and keeps the switch offered so + /// the choice can always be undone. Rewriting a file that already agrees is churn. + static func resolvesMailboard( + loaded: Bool, explicitChoice: Bool?, rampedOn: Bool + ) -> MailboardResolution { + let enabled = explicitChoice ?? rampedOn + return MailboardResolution( + enabled: enabled, fileNeedsWrite: enabled != loaded, + showsSwitch: rampedOn || explicitChoice != nil) + } + + struct MailboardResolution: Equatable { + var enabled: Bool + var fileNeedsWrite: Bool + var showsSwitch: Bool + } } diff --git a/graphcode/Sources/Features/Settings/SettingsView.swift b/graphcode/Sources/Features/Settings/SettingsView.swift index d0e98b20..d927b3af 100644 --- a/graphcode/Sources/Features/Settings/SettingsView.swift +++ b/graphcode/Sources/Features/Settings/SettingsView.swift @@ -188,6 +188,23 @@ struct SettingsView: View { .font(.caption2) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + + // The `mailboard` ramp decides whether the switch is offered at all; the + // daemon-side bit it drives lives in `mailboardEnabled` (`GraphcodeSettings`), + // which the model writes the ramp's answer to at launch. + if model.showsMailboard { + Toggle("Mailboard", isOn: $model.mailboardEnabled) + 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. The daemon enforces " + + "this: off, it refuses every mailboard command. Beta installs start " + + "on; a flip here is remembered even if the rollout later changes." + ) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } footer: { Text( "A strip along the window's bottom listing passes, hand-offs and state changes " diff --git a/graphcode/Tests/FeatureRampsTests.swift b/graphcode/Tests/FeatureRampsTests.swift index d24650bf..a847cb12 100644 --- a/graphcode/Tests/FeatureRampsTests.swift +++ b/graphcode/Tests/FeatureRampsTests.swift @@ -77,4 +77,28 @@ struct FeatureRampsTests { .codespaces, configuration: configuration, channel: "nightly", installID: UUID().uuidString)) } + + @Test + func mailboardShipsBetaOnAndStableOff() { + // The Mailboard ramps the way codespaces no longer does: beta installs first, + // stable waiting for the fetched file to raise it. The baked default is the + // shipped posture, not the end state. + let id = UUID().uuidString + #expect( + FeatureRamps.isEnabled(.mailboard, configuration: nil, channel: "beta", installID: id)) + #expect( + !FeatureRamps.isEnabled(.mailboard, 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: ["mailboard": ["beta": 100, "stable": 100]]) + #expect( + FeatureRamps.isEnabled( + .mailboard, configuration: everywhere, channel: "stable", installID: id)) + let nowhere = FeatureRamps.Configuration(features: ["mailboard": ["beta": 0, "stable": 0]]) + #expect( + !FeatureRamps.isEnabled( + .mailboard, configuration: nowhere, channel: "beta", installID: id)) + } } diff --git a/graphcode/Tests/MailboardCommandTests.swift b/graphcode/Tests/MailboardCommandTests.swift new file mode 100644 index 00000000..816cb36b --- /dev/null +++ b/graphcode/Tests/MailboardCommandTests.swift @@ -0,0 +1,182 @@ +import Foundation +import GraphcodeKit +import MailboardKit +import Testing + +/// The `mailboard` verbs' CLI half: what each spelling parses into and what the board +/// renders as. The daemon half — posting, cursors, watcher wakes — lives in +/// `MailboardTests`; 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 MailboardCommandTests { + // MARK: Parsing + + @Test + func postJoinsTheNoteAndKeepsTheTopicRaw() throws { + // Lower-casing is the daemon's job (one spelling per topic across the graph); + // the CLI carries what was typed. + #expect( + try GraphcodeCommand.parse(["mailboard", "post", "/tmp/x", "staking", "issue", "#12"]) + == .mailboardPost(projectPath: "/tmp/x", topic: nil, text: "staking issue #12")) + #expect( + try GraphcodeCommand.parse( + ["mailboard", "post", "/tmp/x", "--topic", "Claims", "staking", "issue", "#12"]) + == .mailboardPost(projectPath: "/tmp/x", topic: "Claims", text: "staking issue #12")) + #expect( + try GraphcodeCommand.parse( + ["mailboard", "post", "/tmp/x", "staking", "it", "--topic", "claims"]) + == .mailboardPost(projectPath: "/tmp/x", topic: "claims", text: "staking it")) + } + + @Test + func postWithoutANoteIsAMissingNote() { + #expect(throws: GraphcodeCommand.ParseError.missingArgument("note")) { + try GraphcodeCommand.parse(["mailboard", "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(["mailboard", "post", "/tmp/x", "--topic", "build"]) + } + } + + @Test + func syncAndListTakeOnlyAProjectPath() throws { + #expect( + try GraphcodeCommand.parse(["mailboard", "sync", "/tmp/x"]) + == .mailboardSync(projectPath: "/tmp/x")) + #expect( + try GraphcodeCommand.parse(["mailboard", "list", "/tmp/x"]) + == .mailboardList(projectPath: "/tmp/x")) + } + + @Test + func watchDefaultsToEveryPostAndOptsOutWithOff() throws { + #expect( + try GraphcodeCommand.parse(["mailboard", "watch", "/tmp/x"]) + == .mailboardWatch(projectPath: "/tmp/x", on: true, topic: nil)) + #expect( + try GraphcodeCommand.parse(["mailboard", "watch", "/tmp/x", "--topic", "build"]) + == .mailboardWatch(projectPath: "/tmp/x", on: true, topic: "build")) + #expect( + try GraphcodeCommand.parse(["mailboard", "watch", "/tmp/x", "--off"]) + == .mailboardWatch(projectPath: "/tmp/x", on: false, topic: nil)) + #expect( + try GraphcodeCommand.parse(["mailboard", "watch", "/tmp/x", "--topic", "build", "--off"]) + == .mailboardWatch(projectPath: "/tmp/x", on: false, topic: "build")) + } + + @Test + func aMissingProjectPathIsNamedInTheError() { + #expect(throws: GraphcodeCommand.ParseError.missingArgument("project-path")) { + try GraphcodeCommand.parse(["mailboard", "post"]) + } + #expect(throws: GraphcodeCommand.ParseError.missingArgument("project-path")) { + try GraphcodeCommand.parse(["mailboard", "watch", "--off"]) + } + } + + @Test + func unknownMailboardVerbAndOptionAreNamed() { + #expect(throws: GraphcodeCommand.ParseError.unknownCommand("mailboard fetch")) { + try GraphcodeCommand.parse(["mailboard", "fetch", "/tmp/x"]) + } + #expect(throws: GraphcodeCommand.ParseError.unknownOption("--filter")) { + try GraphcodeCommand.parse(["mailboard", "list", "/tmp/x", "--filter", "auth"]) + } + } + + @Test + func helpAnywhereInTheVerbPrintsHelpInsteadOfFailing() throws { + // 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(["mailboard", "--help"]) == .help) + #expect(try GraphcodeCommand.parse(["mailboard", "post", "--help"]) == .help) + #expect(try GraphcodeCommand.parse(["mailboard", "post", "/tmp/x", "-h"]) == .help) + #expect(try GraphcodeCommand.parse(["mailboard", "sync", "--help"]) == .help) + #expect(try GraphcodeCommand.parse(["mailboard", "list", "/tmp/x", "--help"]) == .help) + #expect(try GraphcodeCommand.parse(["mailboard", "watch", "--help"]) == .help) + } + + // MARK: Rendering + + @Test + func theBoardRendersOneLinePerPost() { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + graph.mailboard = [ + MailboardPost( + id: 1, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", + topic: nil, body: "kickoff"), + MailboardPost( + id: 4, at: Date(timeIntervalSince1970: 100), authorID: UUID(), author: "Author", + topic: "claims", body: "issue #12 is mine"), + ] + + let rendered = GraphcodeCommand.renderMailboard(graph) + + #expect(rendered.contains("#1 from a human")) + #expect(rendered.contains("#4 (claims) from Author")) + #expect(rendered.contains("issue #12 is mine")) + } + + @Test + func unreadForReaderUsesItsCursorNotTheCount() { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + var reader = LoopNode(title: "Reader", loopType: .turnBased) + reader.lastMailboardRead = 1 + graph.nodes.append(reader) + graph.mailboard = [ + MailboardPost( + id: 1, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", + topic: nil, body: "already read"), + MailboardPost( + id: 2, at: Date(timeIntervalSince1970: 1), authorID: nil, author: "a human", + topic: nil, body: "still unread"), + ] + + let forReader = GraphcodeCommand.renderMailboard(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.renderMailboard(graph, unreadFor: UUID()).contains("#1")) + } + + @Test + func emptyBoardAndNothingUnreadSaySo() { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + #expect(GraphcodeCommand.renderMailboard(graph).contains("the board is empty")) + + var reader = LoopNode(title: "Reader", loopType: .turnBased) + reader.lastMailboardRead = 3 + graph.nodes.append(reader) + graph.mailboard = [ + MailboardPost( + id: 3, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", + topic: nil, body: "caught up") + ] + + #expect(GraphcodeCommand.renderMailboard(graph, unreadFor: reader.id) == "no unread posts") + } + + @Test + func postedNamesTheNewSequence() { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + #expect(GraphcodeCommand.renderPosted(graph) == "posted") + + graph.mailboard = [ + MailboardPost( + id: 7, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", + topic: "build", body: "build is red") + ] + #expect(GraphcodeCommand.renderPosted(graph) == "posted #7 (build)") + } + + @Test + func helpTextTeachesTheMailboardVerbs() { + for verb in ["mailboard post", "mailboard sync", "mailboard list", "mailboard watch"] { + #expect(GraphcodeCommand.helpText.contains(verb)) + } + } +} diff --git a/graphcode/Tests/MailboardTests.swift b/graphcode/Tests/MailboardTests.swift new file mode 100644 index 00000000..ca726702 --- /dev/null +++ b/graphcode/Tests/MailboardTests.swift @@ -0,0 +1,210 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit +import MailboardKit +import Testing + +/// The Mailboard'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 MailboardTests { + /// Two loops to talk about: an author and a reader. Turn-based so nothing + /// auto-starts a session. + private func makeStore( + enabled: Bool = true, + delivered: LockIsolated<[(UUID, String)]>? = nil, + memory: LockIsolated<[(UUID, String)]>? = nil + ) async -> GraphStore { + let store = GraphStore( + onEnsureSession: { _, _ in }, + onDeliverMessage: { node, message, _ in + delivered?.withValue { $0.append((node.id, message)) } + return true + }, + onAppendMemory: { nodeID, entry in + memory?.withValue { $0.append((nodeID, entry)) } + }, + onMailboardEnabled: { enabled }) + await store.handle(.createNode(NodeDraft(title: "Author", loopType: .turnBased, firstInstruction: "Work"))) + await store.handle(.createNode(NodeDraft(title: "Reader", loopType: .turnBased, firstInstruction: "Work"))) + return store + } + + private func nodeIDs(_ graph: LoopGraph) -> [UUID] { graph.nodes.map(\.id) } + + @Test + func postLandsOnGraphWithSequenceAndAttribution() async { + let store = await makeStore() + let ids = nodeIDs(await store.graph) + + await store.handle(.mailboardPost(text: " issue #12 is mine ", topic: "Claims", from: ids[0])) + + let graph = await store.graph + #expect(graph.mailboard.count == 1) + let post = graph.mailboard[0] + #expect(post.id == 1) + #expect(post.author == "Author") + #expect(post.authorID == ids[0]) + #expect(post.topic == "claims") + #expect(post.body == "issue #12 is mine") + } + + @Test + func postIsRefusedWhileRampHasFeatureOff() async { + let store = await makeStore(enabled: false) + let ids = nodeIDs(await store.graph) + + await store.handle(.mailboardPost(text: "hello", topic: nil, from: ids[0])) + + let graph = await store.graph + #expect(graph.mailboard.isEmpty) + } + + @Test + func emptyAndOversizedPostsAreRefused() async { + let store = await makeStore() + let ids = nodeIDs(await store.graph) + + await store.handle(.mailboardPost(text: " ", topic: nil, from: ids[0])) + await store.handle(.mailboardPost(text: String(repeating: "x", count: 2000), topic: nil, from: ids[0])) + await store.handle(.mailboardPost(text: "ok", topic: String(repeating: "t", count: 100), from: ids[0])) + await store.handle(.mailboardPost(text: "ok", topic: " ", from: ids[0])) + + let graph = await store.graph + #expect(graph.mailboard.isEmpty) + } + + @Test + func idsKeepGrowingAfterPruning() async { + let store = await makeStore() + let ids = nodeIDs(await store.graph) + + for index in 0..<(Mailboard.maxPosts + 5) { + await store.handle(.mailboardPost(text: "post \(index)", topic: nil, from: ids[0])) + } + + let graph = await store.graph + #expect(graph.mailboard.count == Mailboard.maxPosts) + #expect(graph.mailboard.first?.body == "post 5") + #expect(graph.mailboard.last?.id == Mailboard.maxPosts + 5) + } + + @Test + func syncAdvancesCursorAndNeverMovesItBackward() async { + let store = await makeStore() + let ids = nodeIDs(await store.graph) + + await store.handle(.mailboardPost(text: "one", topic: nil, from: ids[0])) + await store.handle(.mailboardSync(from: ids[1])) + var graph = await store.graph + #expect(graph.nodes[id: ids[1]]?.lastMailboardRead == 1) + + await store.handle(.mailboardSync(from: ids[1])) + graph = await store.graph + #expect(graph.nodes[id: ids[1]]?.lastMailboardRead == 1) + } + + @Test + func syncNeedsLoopIdentity() async { + let store = await makeStore() + + await store.handle(.mailboardSync(from: nil)) + + let graph = await store.graph + #expect(graph.nodes.allSatisfy { $0.lastMailboardRead == nil }) + } + + @Test + func watchSubscriptionIsSetAndCleared() async { + let store = await makeStore() + let ids = nodeIDs(await store.graph) + + await store.handle(.mailboardWatch(on: true, topic: "Build", from: ids[1])) + var graph = await store.graph + #expect(graph.nodes[id: ids[1]]?.mailboardWatch == MailboardWatch(topic: "build")) + + await store.handle(.mailboardWatch(on: false, topic: nil, from: ids[1])) + graph = await store.graph + #expect(graph.nodes[id: ids[1]]?.mailboardWatch == nil) + } + + @Test + func matchingWatcherHearsPost() async { + // Without a live idle session the wake is staged to the watcher's memory — + // the durable half of the mailbox, read at the next wake. + let memory = LockIsolated<[(UUID, String)]>([]) + let store = await makeStore(memory: memory) + let ids = nodeIDs(await store.graph) + await store.handle(.mailboardWatch(on: true, topic: "build", from: ids[1])) + + await store.handle(.mailboardPost(text: "build is red", topic: "build", from: ids[0])) + + let staged = memory.value.filter { $0.0 == ids[1] && $0.1.contains("mailboard — new post #1 (build) from Author") } + #expect(staged.count == 1) + #expect(staged[0].1.contains("graphcode mailboard sync")) + } + + @Test + func topicMismatchAndSelfPostDoNotWake() async { + let memory = LockIsolated<[(UUID, String)]>([]) + let store = await makeStore(memory: memory) + let ids = nodeIDs(await store.graph) + await store.handle(.mailboardWatch(on: true, topic: "build", from: ids[1])) + + await store.handle(.mailboardPost(text: "unrelated", topic: "auth", from: ids[0])) + await store.handle(.mailboardWatch(on: false, topic: nil, from: ids[1])) + await store.handle(.mailboardPost(text: "again", topic: "build", from: ids[0])) + + #expect(memory.value.filter { $0.0 == ids[1] && $0.1.contains("mailboard — new post") }.isEmpty) + } + + @Test + func nilTopicWatcherHearsEveryPost() async { + let memory = LockIsolated<[(UUID, String)]>([]) + let store = await makeStore(memory: memory) + let ids = nodeIDs(await store.graph) + await store.handle(.mailboardWatch(on: true, topic: nil, from: ids[1])) + + await store.handle(.mailboardPost(text: "a", topic: "auth", from: ids[0])) + await store.handle(.mailboardPost(text: "b", topic: nil, from: ids[0])) + + #expect(memory.value.filter { $0.0 == ids[1] && $0.1.contains("mailboard — new post") }.count == 2) + } + + @Test + func graphRoundTripsMailboardThroughCodable() throws { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + graph.mailboard = [ + MailboardPost( + id: 3, at: Date(timeIntervalSince1970: 100), authorID: nil, + author: "a human", topic: "t", body: "b") + ] + var node = LoopNode(title: "n", loopType: .turnBased) + node.lastMailboardRead = 3 + node.mailboardWatch = MailboardWatch(topic: "t") + graph.nodes.append(node) + + let data = try JSONEncoder().encode(graph) + let decoded = try JSONDecoder().decode(LoopGraph.self, from: data) + + #expect(decoded.mailboard == graph.mailboard) + #expect(decoded.nodes[0].lastMailboardRead == 3) + #expect(decoded.nodes[0].mailboardWatch == MailboardWatch(topic: "t")) + + // Graphs saved before the Mailboard 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: "mailboard") + var nodes = try #require(raw["nodes"] as? [[String: Any]]) + nodes[0].removeValue(forKey: "lastMailboardRead") + nodes[0].removeValue(forKey: "mailboardWatch") + stripped["nodes"] = nodes + let legacyData = try JSONSerialization.data(withJSONObject: stripped) + let old = try JSONDecoder().decode(LoopGraph.self, from: legacyData) + #expect(old.mailboard.isEmpty) + #expect(old.nodes[0].lastMailboardRead == nil) + #expect(old.nodes[0].mailboardWatch == nil) + } +} diff --git a/graphcode/Tests/SettingsMailboardTests.swift b/graphcode/Tests/SettingsMailboardTests.swift new file mode 100644 index 00000000..b45c460a --- /dev/null +++ b/graphcode/Tests/SettingsMailboardTests.swift @@ -0,0 +1,70 @@ +import Foundation +import Testing + +@testable import graphcode + +/// The Mailboard'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 SettingsMailboardTests { + private func resolution( + loaded: Bool, choice: Bool?, rampedOn: Bool + ) -> SettingsModel.MailboardResolution { + SettingsModel.resolvesMailboard( + loaded: loaded, explicitChoice: choice, rampedOn: rampedOn) + } + + @Test + func theRampDecidesForAnInstallThatHasNeverChosen() { + // First launch on a beta install: the ramp's answer has to reach the file, or the + // daemon — which never sees the ramp — keeps the board off. + #expect( + resolution(loaded: false, choice: nil, rampedOn: true) + == SettingsModel.MailboardResolution( + enabled: true, fileNeedsWrite: true, showsSwitch: true)) + // A stable install the ramp hasn't reached boots off, and the file already + // agrees, so nothing is written. + #expect( + resolution(loaded: false, choice: nil, rampedOn: false) + == SettingsModel.MailboardResolution( + enabled: false, fileNeedsWrite: false, showsSwitch: false)) + } + + @Test + func aRecordedChoiceOutranksTheRamp() { + // An explicit on survives the ramp never — or no longer — offering it, and the + // switch stays offered so the choice can always be undone. + #expect( + resolution(loaded: true, choice: true, rampedOn: false) + == SettingsModel.MailboardResolution( + enabled: true, fileNeedsWrite: false, showsSwitch: true)) + // An explicit off survives the ramp turning everyone on. + #expect( + resolution(loaded: false, choice: false, rampedOn: true) + == SettingsModel.MailboardResolution( + enabled: false, fileNeedsWrite: false, showsSwitch: true)) + } + + @Test + func anAgreeingFileIsNotRewritten() { + // Every launch of a settled install resolves the same answer; rewriting identical + // bytes would be churn. + #expect( + resolution(loaded: true, choice: nil, rampedOn: true) + == SettingsModel.MailboardResolution( + enabled: true, fileNeedsWrite: false, showsSwitch: true)) + } + + @Test + func aRampPulledToZeroReachesTheFile() { + // The kill-switch posture: the ramp drops to 0 under a choice-less install whose + // file still says on — the app is the only writer that can resolve this, and the + // switch goes away with it. + #expect( + resolution(loaded: true, choice: nil, rampedOn: false) + == SettingsModel.MailboardResolution( + enabled: false, fileNeedsWrite: true, showsSwitch: false)) + } +} From 50b1ec454c6af57ffb4a849fd42312035fdb9704 Mon Sep 17 00:00:00 2001 From: scgopi Date: Mon, 31 Aug 2026 09:40:53 -0700 Subject: [PATCH 02/10] 0.1.57-beta6 (build 217): version bump Signed-off-by: scgopi --- Project.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Project.swift b/Project.swift index ee963eb3..584bcc20 100644 --- a/Project.swift +++ b/Project.swift @@ -70,8 +70,8 @@ let project = Project( // (#33). Before that the suffix lived on the tag only, so betas 48/49 // of the 0.1.15 line read "0.1.15" and are told by the build number // apart. - "CFBundleShortVersionString": "0.1.57-beta1", - "CFBundleVersion": "216", + "CFBundleShortVersionString": "0.1.57-beta6", + "CFBundleVersion": "217", ]), resources: [ "graphcode/Resources/**" From f613a665a3dfff9eaf93b952a5caf7255387909c Mon Sep 17 00:00:00 2001 From: scgopi Date: Mon, 31 Aug 2026 20:57:57 -0700 Subject: [PATCH 03/10] Rename Mailboard to Artifactory; mirror shared communication; clean up on delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #229, all three: - Mailboard -> Artifactory everywhere: the module (ArtifactoryKit), the types, the settings bit (artifactoryEnabled), the ramp key, the verbs (graphcode artifactory post|sync|list|watch), the briefing, and the digest line. - Every shared communication is now stored in the artifactory as well: direct sends (node send, immediate or follow-up) land as a 'direct' record, delivered message-edge deliveries as 'direct', handoffs (with their payload) as 'handoff'. Record-only by design — mirroring never rings watchers, or a busy graph would double-deliver everything. Undelivered edge messages are not recorded: the artifactory records what actually was said, and an edge that failed transport said nothing. Cycle re-entries stay out for the same reason heartbeat ticks stay out of memory logs. - Deleting a loop deletes the artifactory posts it authored, alongside the edges, session, and memory teardown delete already performs — one irreversible confirmation covers the whole blast radius, spawned descendants included. Posts where the loop was only the recipient stay (they are the other side's record); node stop keeps everything. Full suite: 1323 tests / 143 suites pass; suite lint clean. Signed-off-by: scgopi --- .../Sources/Artifactory.swift | 22 +- .../Sources/CLI/GraphcodeCommand.swift | 74 ++-- .../Sources/Domain/GraphcodeSettings.swift | 18 +- GraphcodeKit/Sources/Domain/LoopGraph.swift | 14 +- GraphcodeKit/Sources/Domain/LoopNode.swift | 32 +- .../Sources/Domain/SessionBriefing.swift | 26 +- GraphcodeKit/Sources/GraphStore.swift | 183 ++++++---- GraphcodeKit/Sources/IPC/DaemonProtocol.swift | 22 +- GraphcodeKit/Sources/ProjectRegistry.swift | 2 +- .../Sources/Sessions/NodeMemory.swift | 10 +- .../Sources/Sessions/ZmxSessionLauncher.swift | 2 +- Project.swift | 10 +- graphcode-cli/Sources/main.swift | 28 +- graphcode/Sources/Clients/FeatureRamps.swift | 4 +- .../Features/Settings/SettingsModel.swift | 46 +-- .../Features/Settings/SettingsView.swift | 10 +- ...ts.swift => ArtifactoryCommandTests.swift} | 110 +++--- graphcode/Tests/ArtifactoryTests.swift | 336 ++++++++++++++++++ graphcode/Tests/FeatureRampsTests.swift | 16 +- graphcode/Tests/MailboardTests.swift | 210 ----------- ...s.swift => SettingsArtifactoryTests.swift} | 20 +- 21 files changed, 693 insertions(+), 502 deletions(-) rename MailboardKit/Sources/Mailboard.swift => ArtifactoryKit/Sources/Artifactory.swift (84%) rename graphcode/Tests/{MailboardCommandTests.swift => ArtifactoryCommandTests.swift} (51%) create mode 100644 graphcode/Tests/ArtifactoryTests.swift delete mode 100644 graphcode/Tests/MailboardTests.swift rename graphcode/Tests/{SettingsMailboardTests.swift => SettingsArtifactoryTests.swift} (82%) diff --git a/MailboardKit/Sources/Mailboard.swift b/ArtifactoryKit/Sources/Artifactory.swift similarity index 84% rename from MailboardKit/Sources/Mailboard.swift rename to ArtifactoryKit/Sources/Artifactory.swift index 1edbea52..2839dace 100644 --- a/MailboardKit/Sources/Mailboard.swift +++ b/ArtifactoryKit/Sources/Artifactory.swift @@ -1,9 +1,9 @@ import Foundation -/// One post on a Mailboard — the shared, unaddressed message board a graph of loops +/// One post on a Artifactory — 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 Mailboard is the ambient counterpart. A loop drops a note for *whoever +/// while the Artifactory 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, @@ -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 MailboardPost: Codable, Equatable, Identifiable, Sendable { +public struct ArtifactoryPost: Codable, Equatable, Identifiable, Sendable { /// Position in the board's sequence, 1-based. The unread cursor is this number, so /// ids must only ever grow — they are assigned by `GraphStore` from the current /// maximum, never from the post count, which pruning would shrink. @@ -47,10 +47,10 @@ public struct MailboardPost: Codable, Equatable, Identifiable, Sendable { public static let maxTopicBytes = 64 } -/// A loop's standing subscription to its project's Mailboard — what turns the board +/// A loop's standing subscription to its project's Artifactory — 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 MailboardWatch: Codable, Equatable, Sendable { +public struct ArtifactoryWatch: Codable, Equatable, Sendable { public var topic: String? public init(topic: String? = nil) { self.topic = topic } @@ -60,8 +60,8 @@ public struct MailboardWatch: 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 Mailboard { - /// How many posts a board keeps. The oldest fall off first: a Mailboard is a +public enum Artifactory { + /// How many posts a board keeps. The oldest fall off first: a 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. @@ -70,21 +70,21 @@ public enum Mailboard { /// 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: [MailboardPost]) -> Int { + public static func nextID(after posts: [ArtifactoryPost]) -> Int { (posts.map(\.id).max() ?? 0) + 1 } /// The posts a loop with `lastRead` on its cursor has not seen yet. public static func unread( - in posts: [MailboardPost], since lastRead: Int? - ) -> [MailboardPost] { + in posts: [ArtifactoryPost], since lastRead: Int? + ) -> [ArtifactoryPost] { guard let lastRead else { return posts } return posts.filter { $0.id > lastRead } } /// A board that grew past `maxPosts`, oldest first gone. Applied by the store on /// every post so no caller can forget. - public static func pruned(_ posts: [MailboardPost]) -> [MailboardPost] { + public static func pruned(_ posts: [ArtifactoryPost]) -> [ArtifactoryPost] { guard posts.count > maxPosts else { return posts } return Array(posts.suffix(maxPosts)) } diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index b1809c2e..a0fcf5e4 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -1,5 +1,5 @@ import Foundation -import MailboardKit +import ArtifactoryKit /// Argument parsing and output formatting for the `graphcode` CLI /// (docs/03-architecture.md#cli-graphcode). @@ -44,18 +44,18 @@ 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 Mailboard verbs (docs/03-architecture.md#cli-graphcode): the shared, + /// The Artifactory 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 mailboardPost(projectPath: String, topic: String?, text: String) + case artifactoryPost(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. - case mailboardSync(projectPath: String) + case artifactorySync(projectPath: String) /// The whole board, read-only: no command reaches the daemon, no cursor moves. - case mailboardList(projectPath: String) + case artifactoryList(projectPath: String) /// Subscribe (`on: true`, `--topic` filters) or unsubscribe (`--off`) the calling /// loop; like `sync`, the subscription belongs to a loop, not a shell. - case mailboardWatch(projectPath: String, on: Bool, topic: String?) + case artifactoryWatch(projectPath: String, on: Bool, topic: String?) public enum ParseError: Error, Equatable { case unknownCommand(String) @@ -84,13 +84,13 @@ 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 mailboard post [--topic ] + graphcode artifactory post [--topic ] leave a note on the shared board for whoever comes next - graphcode mailboard sync + graphcode artifactory sync read your unread posts and mark the board read - graphcode mailboard list + graphcode artifactory list the whole board, read-only — no cursor moves - graphcode mailboard watch [--topic ] [--off] + graphcode artifactory watch [--topic ] [--off] have matching posts typed into this loop's session as they land; --off stops watching graphcode usage @@ -190,7 +190,7 @@ public enum GraphcodeCommand: Equatable, Sendable { MAILBOARD The shared, unaddressed board: `node send` reaches one peer you already know; - a Mailboard post is a note for whoever comes next, discoverable by loops that + a 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 — @@ -215,9 +215,9 @@ public enum GraphcodeCommand: Equatable, Sendable { graphcode status graphcode node send --follow-up stage work without interrupting an active turn - graphcode mailboard sync + graphcode artifactory sync check what other loops left for you before starting a pass - graphcode mailboard post --topic claims issue #12 is mine + graphcode artifactory 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 @@ -351,8 +351,8 @@ public enum GraphcodeCommand: Equatable, Sendable { throw ParseError.unknownCommand("node \(verb)") } - case "mailboard": - return try parseMailboard(&arguments) + case "artifactory": + return try parseArtifactory(&arguments) case "edge": let verb = try take(&arguments, name: "edge subcommand") @@ -770,27 +770,27 @@ extension GraphcodeCommand { return projects.map { "\($0.name) \($0.path)" }.joined(separator: "\n") } - /// The board for a terminal. `mailboard list` prints the whole thing (`reader` - /// nil); `mailboard sync` passes the reading loop's id and prints only what its - /// cursor has not covered — the subtraction is `Mailboard.unread`, the arithmetic + /// 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 daemon's cursor contract rests on, so the CLI's "unread" and the store's can /// never disagree. - public static func renderMailboard( + public static func renderArtifactory( _ graph: LoopGraph, unreadFor readerID: UUID? = nil ) -> String { - let posts: [MailboardPost] + let posts: [ArtifactoryPost] if let readerID { - posts = Mailboard.unread( - in: graph.mailboard, since: graph.nodes[id: readerID]?.lastMailboardRead) + posts = Artifactory.unread( + in: graph.artifactory, since: graph.nodes[id: readerID]?.lastArtifactoryRead) } else { - posts = graph.mailboard + posts = graph.artifactory } guard !posts.isEmpty else { return readerID == nil - ? "the board is empty — post one: graphcode mailboard post " + ? "the board is empty — post one: graphcode artifactory post " : "no unread posts" } - let label = readerID == nil ? "mailboard" : "mailboard, unread" + let label = readerID == nil ? "artifactory" : "artifactory, unread" var lines = [ "\(graph.project.name) \(label): \(posts.count) post\(posts.count == 1 ? "" : "s")" ] @@ -800,16 +800,16 @@ extension GraphcodeCommand { /// 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: MailboardPost) -> String { + public static func render(_ post: ArtifactoryPost) -> String { let topic = post.topic.map { " (\($0))" } ?? "" let stamp = post.at.formatted(date: .abbreviated, time: .shortened) return "#\(post.id)\(topic) from \(post.author) at \(stamp) — \(post.body)" } - /// `mailboard post`'s answer — the sequence number is what the author's own log and + /// `artifactory 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.mailboard.last else { return "posted" } + guard let post = graph.artifactory.last else { return "posted" } let topic = post.topic.map { " (\($0))" } ?? "" return "posted #\(post.id)\(topic)" } @@ -868,14 +868,14 @@ extension GraphcodeCommand { return .importNodes(projectPath: projectPath, fromZip: zipPath, asChildOf: asChildOf) } - /// The `mailboard` verbs' parsing, split from `parseVerb` the way export/import + /// The `artifactory` verbs' parsing, split from `parseVerb` the way export/import /// were. The note is joined argv words — the `node send`/`node memo` bargain, so - /// `graphcode mailboard post --topic claims issue #12 is mine` needs no + /// `graphcode artifactory post --topic claims issue #12 is mine` needs no /// quoting gymnastics — with `--topic ` riding along in either position. - fileprivate static func parseMailboard( + fileprivate static func parseArtifactory( _ arguments: inout [String] ) throws -> GraphcodeCommand { - let verb = try take(&arguments, name: "mailboard subcommand") + let verb = try take(&arguments, name: "artifactory subcommand") let path = try take(&arguments, name: "project-path") if arguments.contains(where: isHelpFlag) { throw HelpRequested() } switch verb { @@ -891,23 +891,23 @@ extension GraphcodeCommand { } let text = words.joined(separator: " ").trimmingCharacters(in: .whitespaces) guard !text.isEmpty else { throw ParseError.missingArgument("note") } - return .mailboardPost(projectPath: path, topic: flags["topic"], text: text) + return .artifactoryPost(projectPath: path, topic: flags["topic"], text: text) case "sync": try validateFlags(arguments, allowed: []) - return .mailboardSync(projectPath: path) + return .artifactorySync(projectPath: path) case "list": try validateFlags(arguments, allowed: []) - return .mailboardList(projectPath: path) + return .artifactoryList(projectPath: path) case "watch": try validateFlags(arguments, allowed: ["topic", "off"]) let flags = parseFlags(arguments) - return .mailboardWatch(projectPath: path, on: flags["off"] == nil, topic: flags["topic"]) + return .artifactoryWatch(projectPath: path, on: flags["off"] == nil, topic: flags["topic"]) default: - throw ParseError.unknownCommand("mailboard \(verb)") + throw ParseError.unknownCommand("artifactory \(verb)") } } } diff --git a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift index ccef4f5b..90b2d2da 100644 --- a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift +++ b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift @@ -375,20 +375,20 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { /// heartbeat loops immediately without restarting anything. public var daemonHeartbeatEnabled: Bool - /// Whether loops get the **Mailboard** — a shared, unaddressed message board the + /// Whether loops get the **Artifactory** — 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 Mailboard is the ambient + /// for talking to a peer you already know, while the Artifactory 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.mailboard` — beta installs first, stable only when the + /// `FeatureRamps.Feature.artifactory` — 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 `mailboard` + /// (which cannot see ramps or `UserDefaults`) actually enforces: every `artifactory` /// 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 /// `sharesLoops`' is. - public var mailboardEnabled: Bool + public var artifactoryEnabled: Bool /// Whether `graphcoded` keeps the Mac awake while any loop is running /// (`AwakeAssertion`). Off by default and deliberately so: a background process that @@ -418,7 +418,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { summaryUsesModel: Bool = false, visualisesSummaries: Bool = false, daemonHeartbeatEnabled: Bool = false, - mailboardEnabled: Bool = false, + artifactoryEnabled: Bool = false, keepsMacAwakeWhileLoopsRun: Bool = false, worktreePolicies: [String: WorktreeHygienePolicy] = [:] ) { @@ -435,7 +435,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { self.summaryUsesModel = summaryUsesModel self.visualisesSummaries = visualisesSummaries self.daemonHeartbeatEnabled = daemonHeartbeatEnabled - self.mailboardEnabled = mailboardEnabled + self.artifactoryEnabled = artifactoryEnabled self.keepsMacAwakeWhileLoopsRun = keepsMacAwakeWhileLoopsRun self.worktreePolicies = worktreePolicies } @@ -493,8 +493,8 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { // The daemon-side half of the beta ramp: the app writes the ramp-resolved value, // so absent means "no app has spoken yet" and takes the off every non-app flow // (CLI-only machines, hand-edited files) already had. - mailboardEnabled = - try container.decodeIfPresent(Bool.self, forKey: .mailboardEnabled) ?? false + artifactoryEnabled = + try container.decodeIfPresent(Bool.self, forKey: .artifactoryEnabled) ?? false // 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 28ec4352..7ec4f6a4 100644 --- a/GraphcodeKit/Sources/Domain/LoopGraph.swift +++ b/GraphcodeKit/Sources/Domain/LoopGraph.swift @@ -1,6 +1,6 @@ import Foundation import IdentifiedCollections -import MailboardKit +import ArtifactoryKit /// The unit `graphcoded`'s `GraphStore` owns and the graph canvas renders — see /// docs/02-graph-of-loops.md#loopgraph. @@ -20,15 +20,15 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { public var scope: LoopGraphScope public var nodes: IdentifiedArrayOf public var edges: IdentifiedArrayOf - /// The project's Mailboard — every post any loop has dropped onto the shared board, - /// oldest first, capped at `Mailboard.maxPosts`. Kept on the graph rather than in a + /// The project's Artifactory — every post any loop has dropped onto the shared board, + /// oldest first, capped at `Artifactory.maxPosts`. 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 mailboard: [MailboardPost] = [] + public var artifactory: [ArtifactoryPost] = [] public var project: ProjectRef { get { scope.projectRef } @@ -244,7 +244,7 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { // MARK: - Coding private enum CodingKeys: String, CodingKey { - case id, nodes, edges, mailboard + case id, nodes, edges, artifactory /// 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 @@ -259,7 +259,7 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { scope = LoopGraphScope(projectPath: ref.path, name: ref.name) nodes = try container.decodeIfPresent(IdentifiedArrayOf.self, forKey: .nodes) ?? [] edges = try container.decodeIfPresent(IdentifiedArrayOf.self, forKey: .edges) ?? [] - mailboard = try container.decodeIfPresent([MailboardPost].self, forKey: .mailboard) ?? [] + artifactory = try container.decodeIfPresent([ArtifactoryPost].self, forKey: .artifactory) ?? [] } public func encode(to encoder: Encoder) throws { @@ -270,6 +270,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 !mailboard.isEmpty { try container.encode(mailboard, forKey: .mailboard) } + if !artifactory.isEmpty { try container.encode(artifactory, forKey: .artifactory) } } } diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index b43c0bf6..58419c93 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -1,5 +1,5 @@ import Foundation -import MailboardKit +import ArtifactoryKit /// 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. @@ -135,18 +135,18 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// handoff, and custody has to be: stopping or deleting a parent takes its spawned /// descendants with it, while a drawn edge to a peer must never be caught in that. public let createdBy: UUID? - /// The newest Mailboard post this loop has read — `MailboardPost.id` of the last - /// post a `graphcode mailboard sync` showed it. `nil` has not synced yet and makes + /// 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 /// 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 lastMailboardRead: Int? - /// This loop's standing subscription to its project's Mailboard — set and cleared - /// with `graphcode mailboard watch`. Non-nil means every matching post also gets + 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 /// 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 mailboardWatch: MailboardWatch? + public var artifactoryWatch: ArtifactoryWatch? public var state: LoopState public var createdAt: Date @@ -172,8 +172,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { presence: PresenceReading? = nil, metricHistory: [MetricSample] = [], createdBy: UUID? = nil, - lastMailboardRead: Int? = nil, - mailboardWatch: MailboardWatch? = nil, + lastArtifactoryRead: Int? = nil, + artifactoryWatch: ArtifactoryWatch? = nil, state: LoopState = .idle, createdAt: Date = Date() ) { @@ -198,8 +198,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { self.presence = presence self.metricHistory = metricHistory self.createdBy = createdBy - self.lastMailboardRead = lastMailboardRead - self.mailboardWatch = mailboardWatch + self.lastArtifactoryRead = lastArtifactoryRead + self.artifactoryWatch = artifactoryWatch self.state = state self.createdAt = createdAt } @@ -433,7 +433,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 lastMailboardRead, mailboardWatch + case lastArtifactoryRead, artifactoryWatch case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly case summary, board, heartbeatIntervalSeconds } @@ -475,11 +475,11 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { metricHistory = try container.decodeIfPresent([MetricSample].self, forKey: .metricHistory) ?? [] createdBy = try container.decodeIfPresent(UUID.self, forKey: .createdBy) - // Absent from graphs saved before the Mailboard existed — every loop simply has + // Absent from graphs saved before the Artifactory existed — every loop simply has // not read anything yet, which is what `nil` says. - lastMailboardRead = try container.decodeIfPresent(Int.self, forKey: .lastMailboardRead) - mailboardWatch = try container.decodeIfPresent( - MailboardWatch.self, forKey: .mailboardWatch) + lastArtifactoryRead = try container.decodeIfPresent(Int.self, forKey: .lastArtifactoryRead) + artifactoryWatch = try container.decodeIfPresent( + ArtifactoryWatch.self, forKey: .artifactoryWatch) state = try container.decodeIfPresent(LoopState.self, forKey: .state) ?? .idle createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() } diff --git a/GraphcodeKit/Sources/Domain/SessionBriefing.swift b/GraphcodeKit/Sources/Domain/SessionBriefing.swift index abb31bc9..01738c1b 100644 --- a/GraphcodeKit/Sources/Domain/SessionBriefing.swift +++ b/GraphcodeKit/Sources/Domain/SessionBriefing.swift @@ -77,31 +77,37 @@ 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 Mailboard's section exists only while the beta ramp has the feature on: a + // The Artifactory'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. - let mailboardSection = - settings.mailboardEnabled + let artifactorySection = + settings.artifactoryEnabled ? """ - ## The Mailboard — notes for whoever comes next + ## The Artifactory — notes for whoever comes next - `node send` reaches one peer you already know. The Mailboard is the shared + `node send` reaches one peer you already know. The Artifactory 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 mailboard sync \(projectPath) # read what you have not seen, mark it read - graphcode mailboard post \(projectPath) [--topic ] # leave something behind - graphcode mailboard list \(projectPath) # read-only peek, cursor untouched - graphcode mailboard watch \(projectPath) [--topic ] # ring me when new mail lands + graphcode artifactory sync \(projectPath) # read what you have not seen, mark it read + 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 ``` 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. + 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. + Your posts stay on the board after you resolve; they go only if your loop is + deleted. + """ : "" return """ @@ -174,7 +180,7 @@ public enum SessionBriefing { 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. - \(mailboardSection) + \(artifactorySection) ## Remembering across passes Loops that run in cycles get relaunched, and a relaunched session starts fresh. diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index 4d08a5c3..c2c2719c 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -1,5 +1,5 @@ import Foundation -import MailboardKit +import ArtifactoryKit /// Owns the daemon's one `LoopGraph`, applies commands, automatically fires `.handoff` /// edges when a node resolves, keeps time-based nodes' sessions alive, and broadcasts @@ -80,11 +80,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 Mailboard is on — read fresh at every gate — so flipping the Settings + /// Whether the Artifactory 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 onMailboardEnabled: (@Sendable () -> Bool)? + private let onArtifactoryEnabled: (@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 @@ -203,7 +203,7 @@ public actor GraphStore { @Sendable (LoopNode, LoopSummary, String?, String?) async -> SummaryBoard? )? = nil, onBoardsEnabled: (@Sendable () -> Bool)? = nil, - onMailboardEnabled: (@Sendable () -> Bool)? = nil, + onArtifactoryEnabled: (@Sendable () -> Bool)? = nil, subGraphDepth: Int = 0 ) { self.graph = graph @@ -227,7 +227,7 @@ public actor GraphStore { self.onHeartbeatEnabled = onHeartbeatEnabled self.onComposeBoard = onComposeBoard self.onBoardsEnabled = onBoardsEnabled - self.onMailboardEnabled = onMailboardEnabled + self.onArtifactoryEnabled = onArtifactoryEnabled } private func recordMemory(_ nodeID: UUID, _ entry: String) { @@ -371,15 +371,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 .mailboardPost(let text, let topic, let from): - await mailboardPost(text: text, topic: topic, from: from) + case .artifactorySync(let from): + artifactorySync(from: from) - case .mailboardSync(let from): - mailboardSync(from: from) - - case .mailboardWatch(let on, let topic, let from): - mailboardWatch(on: on, topic: topic, from: from) + case .artifactoryWatch(let on, let topic, let from): + artifactoryWatch(on: on, topic: topic, from: from) case .renameNode(let nodeID, let title): renameNode(nodeID, to: title) @@ -1218,34 +1217,34 @@ public actor GraphStore { recordMemory(nodeID, "playbook rolled back\(sender.map { " by \($0)" } ?? "")") } - // MARK: - Mailboard + // MARK: - Artifactory - /// Whether the Mailboard is on, asked fresh at every gate with the refusal said out + /// Whether the Artifactory 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 mailboardIsOn() -> Bool { onMailboardEnabled?() == true } + private func artifactoryIsOn() -> Bool { onArtifactoryEnabled?() == 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 `mailboard sync`. - private func mailboardPost(text: String, topic: String?, from senderID: UUID?) async { - guard mailboardIsOn() else { + /// it with one `artifactory sync`. + private func artifactoryPost(text: String, topic: String?, from senderID: UUID?) async { + guard artifactoryIsOn() else { announceError( - "the Mailboard is off — enable Mailboard in Settings " - + "(mailboardEnabled in ~/.graphcode/settings.json)") + "the Artifactory is off — enable Artifactory in Settings " + + "(artifactoryEnabled in ~/.graphcode/settings.json)") return } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { - announceError("mailboard post refused: empty note") + announceError("artifactory post refused: empty note") return } - guard trimmed.utf8.count <= MailboardPost.maxBodyBytes else { + guard trimmed.utf8.count <= ArtifactoryPost.maxBodyBytes else { announceError( - "mailboard post refused: \(trimmed.utf8.count) bytes is over the " - + "\(MailboardPost.maxBodyBytes)-byte bound — a post is a note to a peer, not " + "artifactory post refused: \(trimmed.utf8.count) bytes is over the " + + "\(ArtifactoryPost.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 } @@ -1253,26 +1252,26 @@ public actor GraphStore { topic.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } ?? Optional.none if let trimmedTopic, trimmedTopic.isEmpty { - announceError("mailboard post refused: an empty topic is no topic — omit it") + announceError("artifactory post refused: an empty topic is no topic — omit it") return } - guard trimmedTopic?.utf8.count ?? 0 <= MailboardPost.maxTopicBytes else { + guard trimmedTopic?.utf8.count ?? 0 <= ArtifactoryPost.maxTopicBytes else { announceError( - "mailboard post refused: topic over \(MailboardPost.maxTopicBytes) bytes") + "artifactory post refused: topic over \(ArtifactoryPost.maxTopicBytes) bytes") return } let author = senderID.flatMap { graph.nodes[id: $0]?.title } ?? "a human" - let post = MailboardPost( - id: Mailboard.nextID(after: graph.mailboard), at: Date(), authorID: senderID, + let post = ArtifactoryPost( + id: Artifactory.nextID(after: graph.artifactory), at: Date(), authorID: senderID, author: author, topic: trimmedTopic, body: trimmed) - graph.mailboard = Mailboard.pruned(graph.mailboard + [post]) + graph.artifactory = Artifactory.pruned(graph.artifactory + [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, "mailboard: posted #\(post.id)\(topicSuffix(post)) — \(post.body)") + senderID, "artifactory: posted #\(post.id)\(topicSuffix(post)) — \(post.body)") } - await wakeMailboardWatchers(about: post) + await wakeArtifactoryWatchers(about: post) } /// The mailbox's ring. Every watcher whose subscription matches hears the post the @@ -1281,62 +1280,86 @@ 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 wakeMailboardWatchers(about post: MailboardPost) async { + private func wakeArtifactoryWatchers(about post: ArtifactoryPost) async { for node in graph.nodes where node.id != post.authorID { - guard let watch = node.mailboardWatch, watch.matches(post.topic) else { continue } + guard let watch = node.artifactoryWatch, watch.matches(post.topic) else { continue } let preview = post.body.utf8.count > 140 ? String(post.body.prefix(140)) + "…" : post.body let nudge = - "mailboard — new post #\(post.id)\(topicSuffix(post)) from \(post.author): " - + "\(preview) — read it with: graphcode mailboard sync \(graph.project.path)" - await deliverAdHocMessage(to: node.id, text: nudge, from: nil, followUp: true) + "artifactory — new post #\(post.id)\(topicSuffix(post)) from \(post.author): " + + "\(preview) — read it with: graphcode artifactory sync \(graph.project.path)" + await deliverAdHocMessage( + to: node.id, text: nudge, from: nil, followUp: true, mirror: false) } } - private func topicSuffix(_ post: MailboardPost) -> String { + private func topicSuffix(_ post: ArtifactoryPost) -> String { post.topic.map { " (\($0))" } ?? "" } + /// Writes a shared communication onto the artifactory — 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. + private func recordArtifactoryCommunication( + from senderID: UUID?, to target: LoopNode, text: String, topic: String + ) { + guard onArtifactoryEnabled?() == 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 { + while body.utf8.count > ArtifactoryPost.maxBodyBytes - 1 { body.removeLast() } + body.append("…") + } + let post = ArtifactoryPost( + id: Artifactory.nextID(after: graph.artifactory), at: Date(), authorID: senderID, + author: sender, topic: topic, body: body) + graph.artifactory = Artifactory.pruned(graph.artifactory + [post]) + } + /// Advances the reading loop's cursor to the newest post — the write half of - /// `graphcode mailboard sync`. Deliberately no memory record: sync is reading, + /// `graphcode artifactory sync`. Deliberately no memory record: sync is reading, /// not learning, and a log line per read would turn the log into a metronome. - private func mailboardSync(from readerID: UUID?) { - guard mailboardIsOn() else { + private func artifactorySync(from readerID: UUID?) { + guard artifactoryIsOn() else { announceError( - "the Mailboard is off — enable Mailboard in Settings " - + "(mailboardEnabled in ~/.graphcode/settings.json)") + "the Artifactory is off — enable Artifactory in Settings " + + "(artifactoryEnabled in ~/.graphcode/settings.json)") return } guard let readerID, graph.nodes[id: readerID] != nil else { announceError( - "mailboard sync needs a loop identity — run it from a loop's session " + "artifactory sync needs a loop identity — run it from a loop's session " + "($ZMX_SESSION); a human reading the board needs no cursor") return } - // Never moves backward: ids only grow (`Mailboard.nextID` is max-plus-one), so + // Never moves backward: ids only grow (`Artifactory.nextID` is max-plus-one), so // the max below only guards a board emptied by something other than pruning. - let latest = graph.mailboard.last?.id ?? 0 + let latest = graph.artifactory.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]?.lastMailboardRead ?? 0 - graph.nodes[id: readerID]?.lastMailboardRead = max(latest, current) + let current = graph.nodes[id: readerID]?.lastArtifactoryRead ?? 0 + graph.nodes[id: readerID]?.lastArtifactoryRead = 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 mailboardWatch(on: Bool, topic: String?, from watcherID: UUID?) { - guard mailboardIsOn() else { + private func artifactoryWatch(on: Bool, topic: String?, from watcherID: UUID?) { + guard artifactoryIsOn() else { announceError( - "the Mailboard is off — enable Mailboard in Settings " - + "(mailboardEnabled in ~/.graphcode/settings.json)") + "the Artifactory is off — enable Artifactory in Settings " + + "(artifactoryEnabled in ~/.graphcode/settings.json)") return } guard let watcherID, graph.nodes[id: watcherID] != nil else { announceError( - "mailboard watch needs a loop identity — run it from a loop's session " + "artifactory 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 } @@ -1345,20 +1368,20 @@ public actor GraphStore { topic.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } ?? Optional.none if let trimmed, trimmed.isEmpty { - announceError("mailboard watch refused: an empty topic is no topic — omit it") + announceError("artifactory watch refused: an empty topic is no topic — omit it") return } - graph.nodes[id: watcherID]?.mailboardWatch = MailboardWatch(topic: trimmed) + graph.nodes[id: watcherID]?.artifactoryWatch = ArtifactoryWatch(topic: trimmed) recordMemory( - watcherID, "mailboard: now watching \(trimmed.map { "'\($0)'" } ?? "all posts")") + watcherID, "artifactory: now watching \(trimmed.map { "'\($0)'" } ?? "all posts")") } else { - guard graph.nodes[id: watcherID]?.mailboardWatch != nil else { - announceError("mailboard: \(graph.nodes[id: watcherID]?.title ?? "this loop") " + guard graph.nodes[id: watcherID]?.artifactoryWatch != nil else { + announceError("artifactory: \(graph.nodes[id: watcherID]?.title ?? "this loop") " + "was not watching anything") return } - graph.nodes[id: watcherID]?.mailboardWatch = nil - recordMemory(watcherID, "mailboard: stopped watching") + graph.nodes[id: watcherID]?.artifactoryWatch = nil + recordMemory(watcherID, "artifactory: stopped watching") } } @@ -1452,6 +1475,12 @@ public actor GraphStore { // memory goes the same way — a log for a loop that no longer exists is litter. terminateSession(node) onRemoveMemory?(node.id) + // And its artifactory posts: delete is the one irreversible action in graphcode, + // and the confirmation that covers edges, session, and memory covers the loop's + // board record too. Posts where this loop was only the *recipient* stay — those + // are the other side's record of a communication that happened. A loop that wants + // its notes to survive should be stopped, not deleted. + graph.artifactory.removeAll { $0.authorID == node.id } // A composite's workers live in its sub-graph, on this node rather than in // `graph.nodes` — the same blind spot `requestStop` covers when stopping, and the @@ -1849,6 +1878,17 @@ public actor GraphStore { undeliveredMessages.append((edgeID, .transportFailed)) 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 + // 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. + var record = text + if record.hasPrefix("[graphcode] ") { record.removeFirst("[graphcode] ".count) } + if record.hasPrefix("\(source.title): ") { + record.removeFirst("\(source.title): ".count) + } + recordArtifactoryCommunication(from: source.id, to: target, text: record, topic: "direct") graph.edges[id: edgeID]?.fireCount += 1 } } @@ -1935,6 +1975,7 @@ public actor GraphStore { let target = graph.nodes[id: edge.to] else { continue } + let payload = await handoffPayload(for: edge, from: source) var parts: [String] = [] if pending.isCycleReentry { let bound = edge.cycleGuard?.maxIterations.map { " of \($0)" } ?? "" @@ -1943,8 +1984,16 @@ public actor GraphStore { + "Continue toward your goal.") } else { parts.append("\(source.title) finished and handed its work off to you.") + // The handoff itself is shared communication and gets its record — with its + // payload, which is the part a later reader actually needs. Cycle re-entries + // are the daemon's own metronome, not a loop saying anything, so they stay + // 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( + from: source.id, to: target, text: record, topic: "handoff") } - if let payload = await handoffPayload(for: edge, from: source) { + if let payload { parts.append(payload) } let message = "[graphcode] " + parts.joined(separator: " ") @@ -2002,7 +2051,8 @@ public actor GraphStore { /// the whole point of the message was that a peer be told something, and pretending /// it landed is the one wrong answer. private func deliverAdHocMessage( - to nodeID: UUID, text: String, from senderID: UUID?, followUp: Bool = false + to nodeID: UUID, text: String, from senderID: UUID?, followUp: Bool = false, + mirror: Bool = true ) async { guard let target = graph.nodes[id: nodeID] else { announceError("message not delivered: no loop \(nodeID) in this graph") @@ -2013,6 +2063,15 @@ 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 + // 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( + from: senderID, to: target, text: trimmed, topic: "direct") + } // Attributed when the sender is a loop in this graph, the way a message edge names // its source — the target should know who's talking without guessing. let sender = senderID.flatMap { graph.nodes[id: $0]?.title } diff --git a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift index 3999bf7e..1203cc19 100644 --- a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift +++ b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift @@ -123,27 +123,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 Mailboard — the shared, unaddressed board (`graphcode - /// mailboard post`) any loop can write to for *whoever comes next*, without naming a + /// 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 /// 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 Mailboard off - /// (`mailboardEnabled` in `~/.graphcode/settings.json`) — a silent no-op would read, + /// shell. Refused outright while the beta ramp has the Artifactory off + /// (`artifactoryEnabled` in `~/.graphcode/settings.json`) — a silent no-op would read, /// to the loop that sent it, as a post nobody answered. - case mailboardPost(text: String, topic: String?, from: UUID?) - /// Mark every post on the Mailboard as read for the calling loop — `graphcode - /// mailboard sync`, the cursor half of reading. The CLI reads the board out of the + 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 /// 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 mailboardSync(from: UUID?) - /// Subscribe (`on: true`) or unsubscribe (`on: false`) the calling loop to Mailboard - /// posts — `graphcode mailboard watch`. A watched post is delivered the way a + 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 /// `--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 mailboardWatch(on: Bool, topic: String?, from: UUID?) + case artifactoryWatch(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 6479a6df..78c13174 100644 --- a/GraphcodeKit/Sources/ProjectRegistry.swift +++ b/GraphcodeKit/Sources/ProjectRegistry.swift @@ -478,7 +478,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. - onMailboardEnabled: { GraphcodeSettingsStore.load().mailboardEnabled }) + onArtifactoryEnabled: { GraphcodeSettingsStore.load().artifactoryEnabled }) 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/NodeMemory.swift b/GraphcodeKit/Sources/Sessions/NodeMemory.swift index 80ef4a8a..dfcc40e8 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, mailboardEnabled: Bool = false, + projectPath: String, nodeID: UUID, artifactoryEnabled: 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 mailboardEnabled { - // The reminder half of the Mailboard. The briefing teaches the board's verbs to + if artifactoryEnabled { + // The reminder half of the Artifactory. 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 Mailboard is on: other loops may have left findings for you. " - + "Check at the start of a pass — graphcode mailboard sync — " + "The project's Artifactory is on: other loops may have left findings for you. " + + "Check at the start of a pass — graphcode artifactory sync — " + "and post anything a peer or successor should not have to rediscover.") lines.append("") } diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 3935eaff..421aabeb 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -636,7 +636,7 @@ public enum ZmxSessionLauncher { projectPath != nil ? NodeMemory.writeWakeDigest( projectPath: projectPath ?? "", nodeID: node.id, - mailboardEnabled: settings.mailboardEnabled) + artifactoryEnabled: settings.artifactoryEnabled) : nil let wakePath: String? if let projectPath, remote != nil { diff --git a/Project.swift b/Project.swift index 584bcc20..be926f9f 100644 --- a/Project.swift +++ b/Project.swift @@ -20,19 +20,19 @@ let project = Project( name: "graphcode", organizationName: "Graphcode", targets: [ - // `MailboardKit` — the Mailboard domain model: one post type, one watch + // `ArtifactoryKit` — the Artifactory 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: "MailboardKit", + name: "ArtifactoryKit", destinations: .macOS, product: .staticFramework, - bundleId: "\(bundleIdPrefix).mailboard", + bundleId: "\(bundleIdPrefix).artifactory", deploymentTargets: .macOS("15.0"), buildableFolders: [ - "MailboardKit/Sources" + "ArtifactoryKit/Sources" ] ), .target( @@ -45,7 +45,7 @@ let project = Project( "GraphcodeKit/Sources" ], dependencies: [ - .target(name: "MailboardKit"), + .target(name: "ArtifactoryKit"), .external(name: "IdentifiedCollections") ] ), diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index 539fb007..ec3bab10 100644 --- a/graphcode-cli/Sources/main.swift +++ b/graphcode-cli/Sources/main.swift @@ -329,7 +329,7 @@ do { projectPath: projectPath, [.graphCommand(projectPath: projectPath, command: .armComposite(nodeID))]) - case .mailboardPost(let projectPath, let topic, let text): + case .artifactoryPost(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 @@ -341,7 +341,7 @@ do { try client.send( .graphCommand( projectPath: projectPath, - command: .mailboardPost(text: text, topic: topic, from: author))) + command: .artifactoryPost(text: text, topic: topic, from: author))) let postVerdict = try client.waitForEvent { event in switch event { case .graphChanged, .errorOccurred: return true @@ -353,24 +353,24 @@ do { print(GraphcodeCommand.renderPosted(graph)) } - case .mailboardSync(let projectPath): - // Attributed like `node send` — and required, the one place a mailboard verb + case .artifactorySync(let projectPath): + // Attributed like `node send` — and required, the one place a artifactory 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 `mailboard list`. + // arrive only after the round trip. Reading without a cursor is `artifactory list`. let reader = SurfaceRef.nodeID( fromZmxSessionName: ProcessInfo.processInfo.environment["ZMX_SESSION"] ?? "") guard let reader else { fail( - "mailboard sync needs a loop identity — run it from inside a loop's session " - + "($ZMX_SESSION); a human reading the board wants `graphcode mailboard list`") + "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`") } try client.send(.openProject(path: projectPath)) let opened = try client.waitForEvent { if case .graphChanged = $0 { return true } else { return false } } try client.send( - .graphCommand(projectPath: projectPath, command: .mailboardSync(from: reader))) + .graphCommand(projectPath: projectPath, command: .artifactorySync(from: reader))) let syncVerdict = try client.waitForEvent { event in switch event { case .graphChanged, .errorOccurred: return true @@ -382,10 +382,10 @@ do { // moves the cursor, so the posts it covers are exactly those above the cursor // there — a post landing mid-command shows up at the next sync, as it should. if case .graphChanged(let graph) = opened { - print(GraphcodeCommand.renderMailboard(graph, unreadFor: reader)) + print(GraphcodeCommand.renderArtifactory(graph, unreadFor: reader)) } - case .mailboardList(let projectPath): + case .artifactoryList(let projectPath): // 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. @@ -394,17 +394,17 @@ do { if case .graphChanged = $0 { return true } else { return false } } if case .graphChanged(let graph) = opened { - print(GraphcodeCommand.renderMailboard(graph)) + print(GraphcodeCommand.renderArtifactory(graph)) } - case .mailboardWatch(let projectPath, let on, let topic): + case .artifactoryWatch(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( - "mailboard watch needs a loop identity — run it from inside a loop's session " + "artifactory 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 client.send(.openProject(path: projectPath)) @@ -412,7 +412,7 @@ do { try client.send( .graphCommand( projectPath: projectPath, - command: .mailboardWatch(on: on, topic: topic, from: watcher))) + command: .artifactoryWatch(on: on, topic: topic, from: watcher))) let watchVerdict = try client.waitForEvent { event in switch event { case .graphChanged, .errorOccurred: return true diff --git a/graphcode/Sources/Clients/FeatureRamps.swift b/graphcode/Sources/Clients/FeatureRamps.swift index c9de713e..b78439cc 100644 --- a/graphcode/Sources/Clients/FeatureRamps.swift +++ b/graphcode/Sources/Clients/FeatureRamps.swift @@ -23,7 +23,7 @@ enum FeatureRamps { enum Feature: String { case codespaces - case mailboard + case 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 +32,7 @@ enum FeatureRamps { var defaultPercents: [String: Int] { switch self { case .codespaces: return ["beta": 100, "stable": 100] - case .mailboard: return ["beta": 100, "stable": 0] + case .artifactory: return ["beta": 100, "stable": 0] } } } diff --git a/graphcode/Sources/Features/Settings/SettingsModel.swift b/graphcode/Sources/Features/Settings/SettingsModel.swift index 21dcbb5a..ab177dbc 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 Mailboard flip, kept apart from `settings` on purpose: the + /// The user's explicit Artifactory 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 mailboardChoiceDefaultsKey = "mailboardChoice" + static let artifactoryChoiceDefaultsKey = "artifactoryChoice" var settings: GraphcodeSettings { didSet { @@ -39,41 +39,41 @@ final class SettingsModel { } } - /// Whether the Settings window offers the Mailboard switch at all — the `mailboard` + /// Whether the Settings window offers the Artifactory switch at all — the `artifactory` /// ramp (`FeatureRamps`), read once at construction for the same reason as /// `AppSidebarView.offersCodespaces`: a ramp change applies from the next launch. A /// switch a stable install was never offered can't have recorded a choice, so an /// install that has chosen keeps its switch even if the ramp later pulls back. - let showsMailboard: Bool + let showsArtifactory: Bool - /// The Mailboard as a switch, following `betaUpdates`' shape — but the daemon - /// enforces this one, so a flip writes `mailboardEnabled` into `settings` (which + /// The Artifactory as a switch, following `betaUpdates`' shape — but the daemon + /// enforces this one, so a flip writes `artifactoryEnabled` into `settings` (which /// saves the file the daemon reads) *and* records the explicit choice that then /// outranks the ramp for good. - var mailboardEnabled: Bool { + var artifactoryEnabled: Bool { didSet { - UserDefaults.standard.set(mailboardEnabled, forKey: Self.mailboardChoiceDefaultsKey) - settings.mailboardEnabled = mailboardEnabled + UserDefaults.standard.set(artifactoryEnabled, forKey: Self.artifactoryChoiceDefaultsKey) + settings.artifactoryEnabled = artifactoryEnabled } } private init() { let loaded = GraphcodeSettingsStore.load() - let mailboard = Self.resolvesMailboard( - loaded: loaded.mailboardEnabled, + let artifactory = Self.resolvesArtifactory( + loaded: loaded.artifactoryEnabled, explicitChoice: - UserDefaults.standard.object(forKey: Self.mailboardChoiceDefaultsKey) as? Bool, - rampedOn: FeatureRamps.isEnabled(.mailboard)) + UserDefaults.standard.object(forKey: Self.artifactoryChoiceDefaultsKey) as? Bool, + rampedOn: FeatureRamps.isEnabled(.artifactory)) var booted = loaded - booted.mailboardEnabled = mailboard.enabled + booted.artifactoryEnabled = artifactory.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 mailboard.fileNeedsWrite { + if artifactory.fileNeedsWrite { GraphcodeSettingsStore.save(booted) } - mailboardEnabled = mailboard.enabled - showsMailboard = mailboard.showsSwitch + artifactoryEnabled = artifactory.enabled + showsArtifactory = artifactory.showsSwitch let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" betaUpdates = @@ -82,25 +82,25 @@ final class SettingsModel { == .beta } - /// The Mailboard's boot decision, separated so tests can pin it without touching + /// The Artifactory'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 — beta first, stable /// only when `ramps.json` raises it — and that answer has to reach /// `~/.graphcode/settings.json` when it differs, because the daemon enforces - /// `mailboardEnabled` out of the file and cannot see ramps or `UserDefaults`. A + /// `artifactoryEnabled` out of the file and cannot see ramps or `UserDefaults`. A /// recorded choice outranks the ramp from then on, and keeps the switch offered so /// the choice can always be undone. Rewriting a file that already agrees is churn. - static func resolvesMailboard( + static func resolvesArtifactory( loaded: Bool, explicitChoice: Bool?, rampedOn: Bool - ) -> MailboardResolution { + ) -> ArtifactoryResolution { let enabled = explicitChoice ?? rampedOn - return MailboardResolution( + return ArtifactoryResolution( enabled: enabled, fileNeedsWrite: enabled != loaded, showsSwitch: rampedOn || explicitChoice != nil) } - struct MailboardResolution: Equatable { + struct ArtifactoryResolution: Equatable { var enabled: Bool var fileNeedsWrite: Bool var showsSwitch: Bool diff --git a/graphcode/Sources/Features/Settings/SettingsView.swift b/graphcode/Sources/Features/Settings/SettingsView.swift index d927b3af..68c19caa 100644 --- a/graphcode/Sources/Features/Settings/SettingsView.swift +++ b/graphcode/Sources/Features/Settings/SettingsView.swift @@ -189,16 +189,16 @@ struct SettingsView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - // The `mailboard` ramp decides whether the switch is offered at all; the - // daemon-side bit it drives lives in `mailboardEnabled` (`GraphcodeSettings`), + // The `artifactory` ramp decides whether the switch is offered at all; the + // daemon-side bit it drives lives in `artifactoryEnabled` (`GraphcodeSettings`), // which the model writes the ramp's answer to at launch. - if model.showsMailboard { - Toggle("Mailboard", isOn: $model.mailboardEnabled) + if model.showsArtifactory { + Toggle("Artifactory", isOn: $model.artifactoryEnabled) 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. The daemon enforces " - + "this: off, it refuses every mailboard command. Beta installs start " + + "this: off, it refuses every artifactory command. Beta installs start " + "on; a flip here is remembered even if the rollout later changes." ) .font(.caption2) diff --git a/graphcode/Tests/MailboardCommandTests.swift b/graphcode/Tests/ArtifactoryCommandTests.swift similarity index 51% rename from graphcode/Tests/MailboardCommandTests.swift rename to graphcode/Tests/ArtifactoryCommandTests.swift index 816cb36b..4a70fffe 100644 --- a/graphcode/Tests/MailboardCommandTests.swift +++ b/graphcode/Tests/ArtifactoryCommandTests.swift @@ -1,14 +1,14 @@ import Foundation import GraphcodeKit -import MailboardKit +import ArtifactoryKit import Testing -/// The `mailboard` verbs' CLI half: what each spelling parses into and what the board +/// The `artifactory` verbs' CLI half: what each spelling parses into and what the board /// renders as. The daemon half — posting, cursors, watcher wakes — lives in -/// `MailboardTests`; here the question is what a loop or human types and what comes +/// `ArtifactoryTests`; 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 MailboardCommandTests { +struct ArtifactoryCommandTests { // MARK: Parsing @Test @@ -16,72 +16,72 @@ struct MailboardCommandTests { // Lower-casing is the daemon's job (one spelling per topic across the graph); // the CLI carries what was typed. #expect( - try GraphcodeCommand.parse(["mailboard", "post", "/tmp/x", "staking", "issue", "#12"]) - == .mailboardPost(projectPath: "/tmp/x", topic: nil, text: "staking issue #12")) + try GraphcodeCommand.parse(["artifactory", "post", "/tmp/x", "staking", "issue", "#12"]) + == .artifactoryPost(projectPath: "/tmp/x", topic: nil, text: "staking issue #12")) #expect( try GraphcodeCommand.parse( - ["mailboard", "post", "/tmp/x", "--topic", "Claims", "staking", "issue", "#12"]) - == .mailboardPost(projectPath: "/tmp/x", topic: "Claims", text: "staking issue #12")) + ["artifactory", "post", "/tmp/x", "--topic", "Claims", "staking", "issue", "#12"]) + == .artifactoryPost(projectPath: "/tmp/x", topic: "Claims", text: "staking issue #12")) #expect( try GraphcodeCommand.parse( - ["mailboard", "post", "/tmp/x", "staking", "it", "--topic", "claims"]) - == .mailboardPost(projectPath: "/tmp/x", topic: "claims", text: "staking it")) + ["artifactory", "post", "/tmp/x", "staking", "it", "--topic", "claims"]) + == .artifactoryPost(projectPath: "/tmp/x", topic: "claims", text: "staking it")) } @Test func postWithoutANoteIsAMissingNote() { #expect(throws: GraphcodeCommand.ParseError.missingArgument("note")) { - try GraphcodeCommand.parse(["mailboard", "post", "/tmp/x"]) + try GraphcodeCommand.parse(["artifactory", "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(["mailboard", "post", "/tmp/x", "--topic", "build"]) + try GraphcodeCommand.parse(["artifactory", "post", "/tmp/x", "--topic", "build"]) } } @Test func syncAndListTakeOnlyAProjectPath() throws { #expect( - try GraphcodeCommand.parse(["mailboard", "sync", "/tmp/x"]) - == .mailboardSync(projectPath: "/tmp/x")) + try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/x"]) + == .artifactorySync(projectPath: "/tmp/x")) #expect( - try GraphcodeCommand.parse(["mailboard", "list", "/tmp/x"]) - == .mailboardList(projectPath: "/tmp/x")) + try GraphcodeCommand.parse(["artifactory", "list", "/tmp/x"]) + == .artifactoryList(projectPath: "/tmp/x")) } @Test func watchDefaultsToEveryPostAndOptsOutWithOff() throws { #expect( - try GraphcodeCommand.parse(["mailboard", "watch", "/tmp/x"]) - == .mailboardWatch(projectPath: "/tmp/x", on: true, topic: nil)) + try GraphcodeCommand.parse(["artifactory", "watch", "/tmp/x"]) + == .artifactoryWatch(projectPath: "/tmp/x", on: true, topic: nil)) #expect( - try GraphcodeCommand.parse(["mailboard", "watch", "/tmp/x", "--topic", "build"]) - == .mailboardWatch(projectPath: "/tmp/x", on: true, topic: "build")) + try GraphcodeCommand.parse(["artifactory", "watch", "/tmp/x", "--topic", "build"]) + == .artifactoryWatch(projectPath: "/tmp/x", on: true, topic: "build")) #expect( - try GraphcodeCommand.parse(["mailboard", "watch", "/tmp/x", "--off"]) - == .mailboardWatch(projectPath: "/tmp/x", on: false, topic: nil)) + try GraphcodeCommand.parse(["artifactory", "watch", "/tmp/x", "--off"]) + == .artifactoryWatch(projectPath: "/tmp/x", on: false, topic: nil)) #expect( - try GraphcodeCommand.parse(["mailboard", "watch", "/tmp/x", "--topic", "build", "--off"]) - == .mailboardWatch(projectPath: "/tmp/x", on: false, topic: "build")) + try GraphcodeCommand.parse(["artifactory", "watch", "/tmp/x", "--topic", "build", "--off"]) + == .artifactoryWatch(projectPath: "/tmp/x", on: false, topic: "build")) } @Test func aMissingProjectPathIsNamedInTheError() { #expect(throws: GraphcodeCommand.ParseError.missingArgument("project-path")) { - try GraphcodeCommand.parse(["mailboard", "post"]) + try GraphcodeCommand.parse(["artifactory", "post"]) } #expect(throws: GraphcodeCommand.ParseError.missingArgument("project-path")) { - try GraphcodeCommand.parse(["mailboard", "watch", "--off"]) + try GraphcodeCommand.parse(["artifactory", "watch", "--off"]) } } @Test - func unknownMailboardVerbAndOptionAreNamed() { - #expect(throws: GraphcodeCommand.ParseError.unknownCommand("mailboard fetch")) { - try GraphcodeCommand.parse(["mailboard", "fetch", "/tmp/x"]) + func unknownArtifactoryVerbAndOptionAreNamed() { + #expect(throws: GraphcodeCommand.ParseError.unknownCommand("artifactory fetch")) { + try GraphcodeCommand.parse(["artifactory", "fetch", "/tmp/x"]) } #expect(throws: GraphcodeCommand.ParseError.unknownOption("--filter")) { - try GraphcodeCommand.parse(["mailboard", "list", "/tmp/x", "--filter", "auth"]) + try GraphcodeCommand.parse(["artifactory", "list", "/tmp/x", "--filter", "auth"]) } } @@ -90,12 +90,12 @@ struct MailboardCommandTests { // 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(["mailboard", "--help"]) == .help) - #expect(try GraphcodeCommand.parse(["mailboard", "post", "--help"]) == .help) - #expect(try GraphcodeCommand.parse(["mailboard", "post", "/tmp/x", "-h"]) == .help) - #expect(try GraphcodeCommand.parse(["mailboard", "sync", "--help"]) == .help) - #expect(try GraphcodeCommand.parse(["mailboard", "list", "/tmp/x", "--help"]) == .help) - #expect(try GraphcodeCommand.parse(["mailboard", "watch", "--help"]) == .help) + #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) } // MARK: Rendering @@ -103,16 +103,16 @@ struct MailboardCommandTests { @Test func theBoardRendersOneLinePerPost() { var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) - graph.mailboard = [ - MailboardPost( + graph.artifactory = [ + ArtifactoryPost( id: 1, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", topic: nil, body: "kickoff"), - MailboardPost( + ArtifactoryPost( id: 4, at: Date(timeIntervalSince1970: 100), authorID: UUID(), author: "Author", topic: "claims", body: "issue #12 is mine"), ] - let rendered = GraphcodeCommand.renderMailboard(graph) + let rendered = GraphcodeCommand.renderArtifactory(graph) #expect(rendered.contains("#1 from a human")) #expect(rendered.contains("#4 (claims) from Author")) @@ -123,41 +123,41 @@ struct MailboardCommandTests { func unreadForReaderUsesItsCursorNotTheCount() { var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) var reader = LoopNode(title: "Reader", loopType: .turnBased) - reader.lastMailboardRead = 1 + reader.lastArtifactoryRead = 1 graph.nodes.append(reader) - graph.mailboard = [ - MailboardPost( + graph.artifactory = [ + ArtifactoryPost( id: 1, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", topic: nil, body: "already read"), - MailboardPost( + ArtifactoryPost( id: 2, at: Date(timeIntervalSince1970: 1), authorID: nil, author: "a human", topic: nil, body: "still unread"), ] - let forReader = GraphcodeCommand.renderMailboard(graph, unreadFor: reader.id) + let forReader = GraphcodeCommand.renderArtifactory(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.renderMailboard(graph, unreadFor: UUID()).contains("#1")) + #expect(GraphcodeCommand.renderArtifactory(graph, unreadFor: UUID()).contains("#1")) } @Test func emptyBoardAndNothingUnreadSaySo() { var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) - #expect(GraphcodeCommand.renderMailboard(graph).contains("the board is empty")) + #expect(GraphcodeCommand.renderArtifactory(graph).contains("the board is empty")) var reader = LoopNode(title: "Reader", loopType: .turnBased) - reader.lastMailboardRead = 3 + reader.lastArtifactoryRead = 3 graph.nodes.append(reader) - graph.mailboard = [ - MailboardPost( + graph.artifactory = [ + ArtifactoryPost( id: 3, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", topic: nil, body: "caught up") ] - #expect(GraphcodeCommand.renderMailboard(graph, unreadFor: reader.id) == "no unread posts") + #expect(GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id) == "no unread posts") } @Test @@ -165,8 +165,8 @@ struct MailboardCommandTests { var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) #expect(GraphcodeCommand.renderPosted(graph) == "posted") - graph.mailboard = [ - MailboardPost( + graph.artifactory = [ + ArtifactoryPost( id: 7, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", topic: "build", body: "build is red") ] @@ -174,8 +174,8 @@ struct MailboardCommandTests { } @Test - func helpTextTeachesTheMailboardVerbs() { - for verb in ["mailboard post", "mailboard sync", "mailboard list", "mailboard watch"] { + func helpTextTeachesTheArtifactoryVerbs() { + for verb in ["artifactory post", "artifactory sync", "artifactory list", "artifactory watch"] { #expect(GraphcodeCommand.helpText.contains(verb)) } } diff --git a/graphcode/Tests/ArtifactoryTests.swift b/graphcode/Tests/ArtifactoryTests.swift new file mode 100644 index 00000000..60d20a63 --- /dev/null +++ b/graphcode/Tests/ArtifactoryTests.swift @@ -0,0 +1,336 @@ +import ArtifactoryKit +import ComposableArchitecture +import Foundation +import GraphcodeKit +import Testing + +/// The Artifactory'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 { + /// Two loops to talk about: an author and a reader. Turn-based so nothing + /// auto-starts a session. + private func makeStore( + enabled: Bool = true, + delivered: LockIsolated<[(UUID, String)]>? = nil, + memory: LockIsolated<[(UUID, String)]>? = nil + ) async -> GraphStore { + let store = GraphStore( + onEnsureSession: { _, _ in }, + onDeliverMessage: { node, message, _ in + delivered?.withValue { $0.append((node.id, message)) } + return true + }, + onAppendMemory: { nodeID, entry in + memory?.withValue { $0.append((nodeID, entry)) } + }, + onArtifactoryEnabled: { enabled }) + await store.handle(.createNode(NodeDraft(title: "Author", loopType: .turnBased, firstInstruction: "Work"))) + await store.handle(.createNode(NodeDraft(title: "Reader", loopType: .turnBased, firstInstruction: "Work"))) + return store + } + + private func nodeIDs(_ graph: LoopGraph) -> [UUID] { graph.nodes.map(\.id) } + + @Test + func postLandsOnGraphWithSequenceAndAttribution() async { + let store = await makeStore() + let ids = nodeIDs(await store.graph) + + await store.handle(.artifactoryPost(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(post.id == 1) + #expect(post.author == "Author") + #expect(post.authorID == ids[0]) + #expect(post.topic == "claims") + #expect(post.body == "issue #12 is mine") + } + + @Test + func postIsRefusedWhileRampHasFeatureOff() async { + let store = await makeStore(enabled: false) + let ids = nodeIDs(await store.graph) + + await store.handle(.artifactoryPost(text: "hello", topic: nil, from: ids[0])) + + let graph = await store.graph + #expect(graph.artifactory.isEmpty) + } + + @Test + func emptyAndOversizedPostsAreRefused() async { + let store = await makeStore() + let ids = nodeIDs(await store.graph) + + await store.handle(.artifactoryPost(text: " ", topic: nil, from: ids[0])) + await store.handle(.artifactoryPost(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])) + + let graph = await store.graph + #expect(graph.artifactory.isEmpty) + } + + @Test + func idsKeepGrowingAfterPruning() async { + let store = await makeStore() + let ids = nodeIDs(await store.graph) + + for index in 0..<(Artifactory.maxPosts + 5) { + await store.handle(.artifactoryPost(text: "post \(index)", topic: nil, from: ids[0])) + } + + let graph = await store.graph + #expect(graph.artifactory.count == Artifactory.maxPosts) + #expect(graph.artifactory.first?.body == "post 5") + #expect(graph.artifactory.last?.id == Artifactory.maxPosts + 5) + } + + @Test + func syncAdvancesCursorAndNeverMovesItBackward() async { + 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])) + var graph = await store.graph + #expect(graph.nodes[id: ids[1]]?.lastArtifactoryRead == 1) + + await store.handle(.artifactorySync(from: ids[1])) + graph = await store.graph + #expect(graph.nodes[id: ids[1]]?.lastArtifactoryRead == 1) + } + + @Test + func syncNeedsLoopIdentity() async { + let store = await makeStore() + + await store.handle(.artifactorySync(from: nil)) + + let graph = await store.graph + #expect(graph.nodes.allSatisfy { $0.lastArtifactoryRead == nil }) + } + + @Test + func watchSubscriptionIsSetAndCleared() async { + let store = await makeStore() + let ids = nodeIDs(await store.graph) + + await store.handle(.artifactoryWatch(on: true, topic: "Build", from: ids[1])) + var graph = await store.graph + #expect(graph.nodes[id: ids[1]]?.artifactoryWatch == ArtifactoryWatch(topic: "build")) + + await store.handle(.artifactoryWatch(on: false, topic: nil, from: ids[1])) + graph = await store.graph + #expect(graph.nodes[id: ids[1]]?.artifactoryWatch == nil) + } + + @Test + func matchingWatcherHearsPost() async { + // Without a live idle session the wake is staged to the watcher's memory — + // the durable half of the mailbox, read at the next wake. + 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(.artifactoryPost(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") + } + #expect(staged.count == 1) + #expect(staged[0].1.contains("graphcode artifactory sync")) + } + + @Test + func topicMismatchAndSelfPostDoNotWake() async { + 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(.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])) + + #expect(memory.value.filter { $0.0 == ids[1] && $0.1.contains("artifactory — new post") }.isEmpty) + } + + @Test + func nilTopicWatcherHearsEveryPost() async { + 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(.artifactoryPost(text: "a", topic: "auth", from: ids[0])) + await store.handle(.artifactoryPost(text: "b", topic: nil, from: ids[0])) + + #expect(memory.value.filter { $0.0 == ids[1] && $0.1.contains("artifactory — new post") }.count == 2) + } + + @Test + func graphRoundTripsArtifactoryThroughCodable() throws { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + graph.artifactory = [ + ArtifactoryPost( + 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") + 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")) + + // Graphs saved before the Artifactory 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") + var nodes = try #require(raw["nodes"] as? [[String: Any]]) + nodes[0].removeValue(forKey: "lastArtifactoryRead") + nodes[0].removeValue(forKey: "artifactoryWatch") + 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) + } +} + +/// 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 + @Test + func directMessageMirrorsOntoArtifactory() async { + let store = await makeStore() + let ids = nodeIDs(await store.graph) + + await store.handle(.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(record.topic == "direct") + #expect(record.author == "Author") + #expect(record.body == "@Reader: the API changed under you") + } + + @Test + func watcherWakesDoNotMirrorThemselves() async { + 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(.artifactoryPost(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") }) + } + + @Test + func deliveredMessageEdgeMirrorsOntoArtifactory() async { + let delivered = LockIsolated<[(UUID, String)]>([]) + let store = await makeStore(delivered: delivered) + let ids = nodeIDs(await store.graph) + + await store.handle( + .createEdge( + from: ids[0], to: ids[1], + spec: EdgeSpec(kind: .message, payloadTransform: .template("specs moved to docs/api.md")))) + await store.handle(.nodeCheckApproved(ids[0])) + + let graph = await store.graph + #expect(graph.edges[0].fired) + let records = graph.artifactory.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 { + let store = await makeStore() + let ids = nodeIDs(await store.graph) + + await store.handle( + .createEdge( + from: ids[0], to: ids[1], + spec: EdgeSpec(kind: .handoff, payloadTransform: .template("branch: fix/auth")))) + await store.handle(.nodeCheckApproved(ids[0])) + + let graph = await store.graph + let records = graph.artifactory.filter { $0.topic == "handoff" } + #expect(records.count == 1) + #expect(records[0].author == "Author") + #expect( + records[0].body + == "@Reader: Author finished and handed its work off to you. branch: fix/auth") + } + + // MARK: - Deletion + + @Test + func deletingALoopRemovesItsArtifactoryPosts() 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(.deleteNode(ids[0])) + + let graph = await store.graph + #expect(graph.nodes.count == 1) + #expect(graph.artifactory.map(\.body) == ["theirs"]) + } + + @Test + func deletingALoopKeepsPostsWhereItWasOnlyTheRecipient() async { + 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) + + 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") + } + + @Test + func deletingALoopRemovesItsSpawnedDescendantsPostsToo() async throws { + let store = await makeStore() + let ids = nodeIDs(await store.graph) + await store.handle( + .createNode( + NodeDraft( + 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(.deleteNode(ids[0])) + + let graph = await store.graph + // Custody: the child went with the parent, and its board record goes too. + #expect(graph.artifactory.isEmpty) + } +} diff --git a/graphcode/Tests/FeatureRampsTests.swift b/graphcode/Tests/FeatureRampsTests.swift index a847cb12..31a7059f 100644 --- a/graphcode/Tests/FeatureRampsTests.swift +++ b/graphcode/Tests/FeatureRampsTests.swift @@ -79,26 +79,26 @@ struct FeatureRampsTests { } @Test - func mailboardShipsBetaOnAndStableOff() { - // The Mailboard ramps the way codespaces no longer does: beta installs first, + func artifactoryShipsBetaOnAndStableOff() { + // The Artifactory ramps the way codespaces no longer does: beta installs first, // stable waiting for the fetched file to raise it. The baked default is the // shipped posture, not the end state. let id = UUID().uuidString #expect( - FeatureRamps.isEnabled(.mailboard, configuration: nil, channel: "beta", installID: id)) + FeatureRamps.isEnabled(.artifactory, configuration: nil, channel: "beta", installID: id)) #expect( - !FeatureRamps.isEnabled(.mailboard, configuration: nil, channel: "stable", installID: id)) + !FeatureRamps.isEnabled(.artifactory, 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: ["mailboard": ["beta": 100, "stable": 100]]) + features: ["artifactory": ["beta": 100, "stable": 100]]) #expect( FeatureRamps.isEnabled( - .mailboard, configuration: everywhere, channel: "stable", installID: id)) - let nowhere = FeatureRamps.Configuration(features: ["mailboard": ["beta": 0, "stable": 0]]) + .artifactory, configuration: everywhere, channel: "stable", installID: id)) + let nowhere = FeatureRamps.Configuration(features: ["artifactory": ["beta": 0, "stable": 0]]) #expect( !FeatureRamps.isEnabled( - .mailboard, configuration: nowhere, channel: "beta", installID: id)) + .artifactory, configuration: nowhere, channel: "beta", installID: id)) } } diff --git a/graphcode/Tests/MailboardTests.swift b/graphcode/Tests/MailboardTests.swift deleted file mode 100644 index ca726702..00000000 --- a/graphcode/Tests/MailboardTests.swift +++ /dev/null @@ -1,210 +0,0 @@ -import ComposableArchitecture -import Foundation -import GraphcodeKit -import MailboardKit -import Testing - -/// The Mailboard'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 MailboardTests { - /// Two loops to talk about: an author and a reader. Turn-based so nothing - /// auto-starts a session. - private func makeStore( - enabled: Bool = true, - delivered: LockIsolated<[(UUID, String)]>? = nil, - memory: LockIsolated<[(UUID, String)]>? = nil - ) async -> GraphStore { - let store = GraphStore( - onEnsureSession: { _, _ in }, - onDeliverMessage: { node, message, _ in - delivered?.withValue { $0.append((node.id, message)) } - return true - }, - onAppendMemory: { nodeID, entry in - memory?.withValue { $0.append((nodeID, entry)) } - }, - onMailboardEnabled: { enabled }) - await store.handle(.createNode(NodeDraft(title: "Author", loopType: .turnBased, firstInstruction: "Work"))) - await store.handle(.createNode(NodeDraft(title: "Reader", loopType: .turnBased, firstInstruction: "Work"))) - return store - } - - private func nodeIDs(_ graph: LoopGraph) -> [UUID] { graph.nodes.map(\.id) } - - @Test - func postLandsOnGraphWithSequenceAndAttribution() async { - let store = await makeStore() - let ids = nodeIDs(await store.graph) - - await store.handle(.mailboardPost(text: " issue #12 is mine ", topic: "Claims", from: ids[0])) - - let graph = await store.graph - #expect(graph.mailboard.count == 1) - let post = graph.mailboard[0] - #expect(post.id == 1) - #expect(post.author == "Author") - #expect(post.authorID == ids[0]) - #expect(post.topic == "claims") - #expect(post.body == "issue #12 is mine") - } - - @Test - func postIsRefusedWhileRampHasFeatureOff() async { - let store = await makeStore(enabled: false) - let ids = nodeIDs(await store.graph) - - await store.handle(.mailboardPost(text: "hello", topic: nil, from: ids[0])) - - let graph = await store.graph - #expect(graph.mailboard.isEmpty) - } - - @Test - func emptyAndOversizedPostsAreRefused() async { - let store = await makeStore() - let ids = nodeIDs(await store.graph) - - await store.handle(.mailboardPost(text: " ", topic: nil, from: ids[0])) - await store.handle(.mailboardPost(text: String(repeating: "x", count: 2000), topic: nil, from: ids[0])) - await store.handle(.mailboardPost(text: "ok", topic: String(repeating: "t", count: 100), from: ids[0])) - await store.handle(.mailboardPost(text: "ok", topic: " ", from: ids[0])) - - let graph = await store.graph - #expect(graph.mailboard.isEmpty) - } - - @Test - func idsKeepGrowingAfterPruning() async { - let store = await makeStore() - let ids = nodeIDs(await store.graph) - - for index in 0..<(Mailboard.maxPosts + 5) { - await store.handle(.mailboardPost(text: "post \(index)", topic: nil, from: ids[0])) - } - - let graph = await store.graph - #expect(graph.mailboard.count == Mailboard.maxPosts) - #expect(graph.mailboard.first?.body == "post 5") - #expect(graph.mailboard.last?.id == Mailboard.maxPosts + 5) - } - - @Test - func syncAdvancesCursorAndNeverMovesItBackward() async { - let store = await makeStore() - let ids = nodeIDs(await store.graph) - - await store.handle(.mailboardPost(text: "one", topic: nil, from: ids[0])) - await store.handle(.mailboardSync(from: ids[1])) - var graph = await store.graph - #expect(graph.nodes[id: ids[1]]?.lastMailboardRead == 1) - - await store.handle(.mailboardSync(from: ids[1])) - graph = await store.graph - #expect(graph.nodes[id: ids[1]]?.lastMailboardRead == 1) - } - - @Test - func syncNeedsLoopIdentity() async { - let store = await makeStore() - - await store.handle(.mailboardSync(from: nil)) - - let graph = await store.graph - #expect(graph.nodes.allSatisfy { $0.lastMailboardRead == nil }) - } - - @Test - func watchSubscriptionIsSetAndCleared() async { - let store = await makeStore() - let ids = nodeIDs(await store.graph) - - await store.handle(.mailboardWatch(on: true, topic: "Build", from: ids[1])) - var graph = await store.graph - #expect(graph.nodes[id: ids[1]]?.mailboardWatch == MailboardWatch(topic: "build")) - - await store.handle(.mailboardWatch(on: false, topic: nil, from: ids[1])) - graph = await store.graph - #expect(graph.nodes[id: ids[1]]?.mailboardWatch == nil) - } - - @Test - func matchingWatcherHearsPost() async { - // Without a live idle session the wake is staged to the watcher's memory — - // the durable half of the mailbox, read at the next wake. - let memory = LockIsolated<[(UUID, String)]>([]) - let store = await makeStore(memory: memory) - let ids = nodeIDs(await store.graph) - await store.handle(.mailboardWatch(on: true, topic: "build", from: ids[1])) - - await store.handle(.mailboardPost(text: "build is red", topic: "build", from: ids[0])) - - let staged = memory.value.filter { $0.0 == ids[1] && $0.1.contains("mailboard — new post #1 (build) from Author") } - #expect(staged.count == 1) - #expect(staged[0].1.contains("graphcode mailboard sync")) - } - - @Test - func topicMismatchAndSelfPostDoNotWake() async { - let memory = LockIsolated<[(UUID, String)]>([]) - let store = await makeStore(memory: memory) - let ids = nodeIDs(await store.graph) - await store.handle(.mailboardWatch(on: true, topic: "build", from: ids[1])) - - await store.handle(.mailboardPost(text: "unrelated", topic: "auth", from: ids[0])) - await store.handle(.mailboardWatch(on: false, topic: nil, from: ids[1])) - await store.handle(.mailboardPost(text: "again", topic: "build", from: ids[0])) - - #expect(memory.value.filter { $0.0 == ids[1] && $0.1.contains("mailboard — new post") }.isEmpty) - } - - @Test - func nilTopicWatcherHearsEveryPost() async { - let memory = LockIsolated<[(UUID, String)]>([]) - let store = await makeStore(memory: memory) - let ids = nodeIDs(await store.graph) - await store.handle(.mailboardWatch(on: true, topic: nil, from: ids[1])) - - await store.handle(.mailboardPost(text: "a", topic: "auth", from: ids[0])) - await store.handle(.mailboardPost(text: "b", topic: nil, from: ids[0])) - - #expect(memory.value.filter { $0.0 == ids[1] && $0.1.contains("mailboard — new post") }.count == 2) - } - - @Test - func graphRoundTripsMailboardThroughCodable() throws { - var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) - graph.mailboard = [ - MailboardPost( - id: 3, at: Date(timeIntervalSince1970: 100), authorID: nil, - author: "a human", topic: "t", body: "b") - ] - var node = LoopNode(title: "n", loopType: .turnBased) - node.lastMailboardRead = 3 - node.mailboardWatch = MailboardWatch(topic: "t") - graph.nodes.append(node) - - let data = try JSONEncoder().encode(graph) - let decoded = try JSONDecoder().decode(LoopGraph.self, from: data) - - #expect(decoded.mailboard == graph.mailboard) - #expect(decoded.nodes[0].lastMailboardRead == 3) - #expect(decoded.nodes[0].mailboardWatch == MailboardWatch(topic: "t")) - - // Graphs saved before the Mailboard 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: "mailboard") - var nodes = try #require(raw["nodes"] as? [[String: Any]]) - nodes[0].removeValue(forKey: "lastMailboardRead") - nodes[0].removeValue(forKey: "mailboardWatch") - stripped["nodes"] = nodes - let legacyData = try JSONSerialization.data(withJSONObject: stripped) - let old = try JSONDecoder().decode(LoopGraph.self, from: legacyData) - #expect(old.mailboard.isEmpty) - #expect(old.nodes[0].lastMailboardRead == nil) - #expect(old.nodes[0].mailboardWatch == nil) - } -} diff --git a/graphcode/Tests/SettingsMailboardTests.swift b/graphcode/Tests/SettingsArtifactoryTests.swift similarity index 82% rename from graphcode/Tests/SettingsMailboardTests.swift rename to graphcode/Tests/SettingsArtifactoryTests.swift index b45c460a..67b7aee8 100644 --- a/graphcode/Tests/SettingsMailboardTests.swift +++ b/graphcode/Tests/SettingsArtifactoryTests.swift @@ -3,16 +3,16 @@ import Testing @testable import graphcode -/// The Mailboard's boot decision in the app: the ramp answers for an install that has +/// The Artifactory'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 SettingsMailboardTests { +struct SettingsArtifactoryTests { private func resolution( loaded: Bool, choice: Bool?, rampedOn: Bool - ) -> SettingsModel.MailboardResolution { - SettingsModel.resolvesMailboard( + ) -> SettingsModel.ArtifactoryResolution { + SettingsModel.resolvesArtifactory( loaded: loaded, explicitChoice: choice, rampedOn: rampedOn) } @@ -22,13 +22,13 @@ struct SettingsMailboardTests { // daemon — which never sees the ramp — keeps the board off. #expect( resolution(loaded: false, choice: nil, rampedOn: true) - == SettingsModel.MailboardResolution( + == SettingsModel.ArtifactoryResolution( enabled: true, fileNeedsWrite: true, showsSwitch: true)) // A stable install the ramp hasn't reached boots off, and the file already // agrees, so nothing is written. #expect( resolution(loaded: false, choice: nil, rampedOn: false) - == SettingsModel.MailboardResolution( + == SettingsModel.ArtifactoryResolution( enabled: false, fileNeedsWrite: false, showsSwitch: false)) } @@ -38,12 +38,12 @@ struct SettingsMailboardTests { // switch stays offered so the choice can always be undone. #expect( resolution(loaded: true, choice: true, rampedOn: false) - == SettingsModel.MailboardResolution( + == SettingsModel.ArtifactoryResolution( enabled: true, fileNeedsWrite: false, showsSwitch: true)) // An explicit off survives the ramp turning everyone on. #expect( resolution(loaded: false, choice: false, rampedOn: true) - == SettingsModel.MailboardResolution( + == SettingsModel.ArtifactoryResolution( enabled: false, fileNeedsWrite: false, showsSwitch: true)) } @@ -53,7 +53,7 @@ struct SettingsMailboardTests { // bytes would be churn. #expect( resolution(loaded: true, choice: nil, rampedOn: true) - == SettingsModel.MailboardResolution( + == SettingsModel.ArtifactoryResolution( enabled: true, fileNeedsWrite: false, showsSwitch: true)) } @@ -64,7 +64,7 @@ struct SettingsMailboardTests { // switch goes away with it. #expect( resolution(loaded: true, choice: nil, rampedOn: false) - == SettingsModel.MailboardResolution( + == SettingsModel.ArtifactoryResolution( enabled: false, fileNeedsWrite: true, showsSwitch: false)) } } From 833e4e2bf2c310b6c60562fa02bbd702e105512d Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 00:33:11 -0700 Subject: [PATCH 04/10] Review round: composites inherit the gate; fresh base; hygiene and bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review of #229, addressed in full: - Major 1: the artifactory gate forwards into sub-graph stores (subGraphStore and runInSubGraph), so a piloted composite's workers can post, sync and watch — and their communication mirrors — instead of being refused by a nil gate while their briefing teaches the verbs. nil still means off; forwarding, not a nil-means-on reading, was the fix. - Major 2: merged origin/main (was based on 1ecdec1; main had moved to 0.1.57/221) — conflict resolution keeps both sides' fields; version is now 0.1.58-beta1 (build 222), past main's counter instead of colliding with it. The CLI stamp uses a fixed-dateFormat DateFormatter with a pinned locale, the one Foundation API Linux CI cannot argue with. - Minor 3: mirror truncation reserves room for the ellipsis (the bound is now really 1024 bytes, not 1026). - Minor 4: the sync race comment now states the race honestly instead of claiming the opposite of the behavior. - Minor 5: mirrored direct/handoff records never ring watchers, so a watch on those topics alone stays silent — documented in the help and the briefing. - Minor 6: MAILBOARD header and 'a Artifactory' grammar slips fixed. - Minor 7: OrderedImports across the five files; all touched files pass swift-format --strict, repo-wide directories included. - Minor 8: imported loops start with a clean cursor — a stale number from the source board could hide this board's mail forever. Watch travels as a preference. - Nits 9, 10, 12: a foreign loop's post reads 'an outside loop' (id kept honestly); the briefing interpolates inline so off means byte-for-byte the pre-Artifactory document; watch --off when not watching is a no-op, not an error; help block spacing; the topic doc sentence now says what the implementation does. Nit 11 (renderPosted sequence echo under a concurrent post) is accepted as-is: the ack pattern is racy by design and the post body is what matters. - Tests: refusal announcement, mirror truncation bound, live-idle watcher delivery, composite gate inheritance, import cursor reset, watch-off idempotence, settings round-trip, briefing on/off, wake digest on/off. Full suite: 1371 tests / 144 suites pass. Signed-off-by: scgopi --- ArtifactoryKit/Sources/Artifactory.swift | 19 +- .../Sources/CLI/GraphcodeCommand.swift | 18 +- GraphcodeKit/Sources/Domain/LoopGraph.swift | 2 +- GraphcodeKit/Sources/Domain/LoopNode.swift | 2 +- .../Sources/Domain/SessionBriefing.swift | 16 +- GraphcodeKit/Sources/GraphStore.swift | 42 +++- graphcode-cli/Sources/main.swift | 6 +- graphcode/Tests/ArtifactoryCommandTests.swift | 2 +- graphcode/Tests/ArtifactoryTests.swift | 185 +++++++++++++++++- 9 files changed, 254 insertions(+), 38 deletions(-) diff --git a/ArtifactoryKit/Sources/Artifactory.swift b/ArtifactoryKit/Sources/Artifactory.swift index 2839dace..677e90b4 100644 --- a/ArtifactoryKit/Sources/Artifactory.swift +++ b/ArtifactoryKit/Sources/Artifactory.swift @@ -1,6 +1,6 @@ import Foundation -/// One post on a Artifactory — the shared, unaddressed message board a graph of loops +/// One post on an Artifactory — 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 @@ -25,11 +25,22 @@ public struct ArtifactoryPost: Codable, Equatable, Identifiable, Sendable { /// what it uses to reply in person with `node send`. public let author: String /// An optional label for threads that keep themselves together — `auth`, `build`, - /// `issues`. A watcher subscribed to a topic only hears matching posts; `nil` posts - /// reach watchers of every topic except the ones that asked for another. + /// `issues`. A watcher subscribed to a topic only hears posts labelled exactly that + /// way; a watcher with no topic hears everything. public let topic: String? public let body: String + /// Cached formatter for CLI rendering — one `DateFormatter` per process rather than + /// per post, and a fixed `dateFormat` with a pinned locale rather than + /// `Date.formatted` or named `DateFormatter.Style` cases, neither of which is + /// something to find out about from the Linux CI toolchains. + public static let stampFormat: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MMM d, HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() + public init( id: Int, at: Date, authorID: UUID?, author: String, topic: String?, body: String ) { @@ -61,7 +72,7 @@ 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 posts a board keeps. The oldest fall off first: a Artifactory is a + /// How many posts 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. diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index 8aae6d4b..bb7b69d0 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -1,5 +1,5 @@ -import Foundation import ArtifactoryKit +import Foundation /// Argument parsing and output formatting for the `graphcode` CLI /// (docs/03-architecture.md#cli-graphcode). @@ -199,18 +199,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 - MAILBOARD + ARTIFACTORY The shared, unaddressed board: `node send` reaches one peer you already know; - a Artifactory post is a note for whoever comes next, discoverable by loops that + 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). - - + 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. EXIT CODES 0 done 1 bad usage, or graphcoded refused the command @@ -821,7 +822,10 @@ extension GraphcodeCommand { /// a loop reads a note the same way everywhere it meets one. public static func render(_ post: ArtifactoryPost) -> String { let topic = post.topic.map { " (\($0))" } ?? "" - let stamp = post.at.formatted(date: .abbreviated, time: .shortened) + // `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) return "#\(post.id)\(topic) from \(post.author) at \(stamp) — \(post.body)" } diff --git a/GraphcodeKit/Sources/Domain/LoopGraph.swift b/GraphcodeKit/Sources/Domain/LoopGraph.swift index 7ec4f6a4..5046a55c 100644 --- a/GraphcodeKit/Sources/Domain/LoopGraph.swift +++ b/GraphcodeKit/Sources/Domain/LoopGraph.swift @@ -1,6 +1,6 @@ +import ArtifactoryKit import Foundation import IdentifiedCollections -import ArtifactoryKit /// The unit `graphcoded`'s `GraphStore` owns and the graph canvas renders — see /// docs/02-graph-of-loops.md#loopgraph. diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index 82ba52a8..03593aca 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -1,5 +1,5 @@ -import Foundation import ArtifactoryKit +import Foundation /// One node in a graph of loops: a unit of agentic work with a well-defined hand-off /// contract, running inside a real CLI session. See docs/02-graph-of-loops.md. diff --git a/GraphcodeKit/Sources/Domain/SessionBriefing.swift b/GraphcodeKit/Sources/Domain/SessionBriefing.swift index 01738c1b..e920fd96 100644 --- a/GraphcodeKit/Sources/Domain/SessionBriefing.swift +++ b/GraphcodeKit/Sources/Domain/SessionBriefing.swift @@ -79,10 +79,14 @@ public enum SessionBriefing { """ // The Artifactory'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. + // 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 ? """ + + ## The Artifactory — notes for whoever comes next `node send` reaches one peer you already know. The Artifactory is the shared @@ -105,9 +109,9 @@ public enum SessionBriefing { 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. - Your posts stay on the board after you resolve; they go only if your loop is - deleted. - + Those mirrored records are the record, not the delivery — they never ring a + watcher, so watching only those topics stays silent. Your posts stay on the + board after you resolve; they go only if your loop is deleted. """ : "" return """ @@ -179,8 +183,8 @@ 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.\(artifactorySection) + ## Remembering across passes Loops that run in cycles get relaunched, and a relaunched session starts fresh. diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index dd9b3adc..feb27712 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -1,5 +1,5 @@ -import Foundation import ArtifactoryKit +import Foundation /// Owns the daemon's one `LoopGraph`, applies commands, automatically fires `.handoff` /// edges when a node resolves, keeps time-based nodes' sessions alive, and broadcasts @@ -575,6 +575,13 @@ public actor GraphStore { onRefinePlaybook: onRefinePlaybook, onRollbackPlaybook: onRollbackPlaybook, onAnnounceError: effects.errors.append, + // The board's gate forwards like any other side effect: a loop inside a piloted + // composite is a real loop whose session got the standard briefing — teaching + // verbs the child store would refuse is exactly the incoherence the gate exists + // 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, goalCache: goalCache, recurrence: effects.recurrence, subGraphDepth: subGraphDepth + 1) @@ -1354,7 +1361,15 @@ public actor GraphStore { "artifactory post refused: topic over \(ArtifactoryPost.maxTopicBytes) bytes") return } - let author = senderID.flatMap { graph.nodes[id: $0]?.title } ?? "a human" + // A foreign loop's id (a sender from another graph, addressing this board + // directly) is kept honestly but never reads as a member: attribution says + // "outside" so no reader takes its post for a peer's. + let author: String + if let senderID, let title = graph.nodes[id: senderID]?.title { + author = title + } else { + author = senderID == nil ? "a human" : "an outside loop" + } let post = ArtifactoryPost( id: Artifactory.nextID(after: graph.artifactory), at: Date(), authorID: senderID, author: author, topic: trimmedTopic, body: trimmed) @@ -1406,7 +1421,9 @@ public actor GraphStore { let sender = senderID.flatMap { graph.nodes[id: $0]?.title } ?? "a human" var body = "@\(target.title): \(text)" if body.utf8.count > ArtifactoryPost.maxBodyBytes { - while body.utf8.count > ArtifactoryPost.maxBodyBytes - 1 { body.removeLast() } + // 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() } body.append("…") } let post = ArtifactoryPost( @@ -1469,13 +1486,13 @@ public actor GraphStore { recordMemory( watcherID, "artifactory: now watching \(trimmed.map { "'\($0)'" } ?? "all posts")") } else { - guard graph.nodes[id: watcherID]?.artifactoryWatch != nil else { - announceError("artifactory: \(graph.nodes[id: watcherID]?.title ?? "this loop") " - + "was not watching anything") - return + // 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") } graph.nodes[id: watcherID]?.artifactoryWatch = nil - recordMemory(watcherID, "artifactory: stopped watching") } } @@ -1506,6 +1523,14 @@ public actor GraphStore { return } graph = plan.mergedGraph + // An imported loop's cursor describes the board it came from. On this board it is + // worse than meaningless: until this graph's ids overtake that number, sync keeps + // reporting nothing new — mail that exists and is never shown. A fresh identity + // 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 + } for (oldID, entries) in request.memoryByNodeID { guard let newID = plan.idMapping[oldID] else { continue } for entry in entries { @@ -2410,6 +2435,7 @@ public actor GraphStore { onRefinePlaybook: onRefinePlaybook, onRollbackPlaybook: onRollbackPlaybook, onAnnounceError: effects.errors.append, + onArtifactoryEnabled: onArtifactoryEnabled, goalCache: goalCache, recurrence: effects.recurrence, subGraphDepth: subGraphDepth + 1) diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index ade156a4..ff2c0aec 100644 --- a/graphcode-cli/Sources/main.swift +++ b/graphcode-cli/Sources/main.swift @@ -395,7 +395,11 @@ do { if case .errorOccurred(let message) = syncVerdict { fail(message) } // Unread is computed from the snapshot `openProject` already delivered: sync only // moves the cursor, so the posts it covers are exactly those above the cursor - // there — a post landing mid-command shows up at the next sync, as it should. + // there. Known race, accepted: a post landing between that snapshot and the + // daemon advancing the cursor is marked read without ever having been printed. + // The window is one round-trip wide and a watcher would have heard the post live + // anyway; fixing it properly means syncing to the highest *printed* id rather + // than to latest, which nothing so far has needed. if case .graphChanged(let graph) = opened { print(GraphcodeCommand.renderArtifactory(graph, unreadFor: reader)) } diff --git a/graphcode/Tests/ArtifactoryCommandTests.swift b/graphcode/Tests/ArtifactoryCommandTests.swift index 4a70fffe..368a645a 100644 --- a/graphcode/Tests/ArtifactoryCommandTests.swift +++ b/graphcode/Tests/ArtifactoryCommandTests.swift @@ -1,6 +1,6 @@ +import ArtifactoryKit import Foundation import GraphcodeKit -import ArtifactoryKit import Testing /// The `artifactory` verbs' CLI half: what each spelling parses into and what the board diff --git a/graphcode/Tests/ArtifactoryTests.swift b/graphcode/Tests/ArtifactoryTests.swift index 60d20a63..9b5546c2 100644 --- a/graphcode/Tests/ArtifactoryTests.swift +++ b/graphcode/Tests/ArtifactoryTests.swift @@ -14,7 +14,9 @@ struct ArtifactoryTests { private func makeStore( enabled: Bool = true, delivered: LockIsolated<[(UUID, String)]>? = nil, - memory: LockIsolated<[(UUID, String)]>? = nil + memory: LockIsolated<[(UUID, String)]>? = nil, + errors: LockIsolated<[String]>? = nil, + presence: Presence? = nil ) async -> GraphStore { let store = GraphStore( onEnsureSession: { _, _ in }, @@ -22,12 +24,18 @@ struct ArtifactoryTests { delivered?.withValue { $0.append((node.id, message)) } return true }, + onReadPresence: presence.map { reading in + { _, _ in PresenceReading(presence: reading, confidence: .reported) } + }, onAppendMemory: { nodeID, entry in memory?.withValue { $0.append((nodeID, entry)) } }, + onAnnounceError: { message in errors?.withValue { $0.append(message) } }, onArtifactoryEnabled: { enabled }) - await store.handle(.createNode(NodeDraft(title: "Author", loopType: .turnBased, firstInstruction: "Work"))) - await store.handle(.createNode(NodeDraft(title: "Reader", loopType: .turnBased, firstInstruction: "Work"))) + await store.handle( + .createNode(NodeDraft(title: "Author", loopType: .turnBased, firstInstruction: "Work"))) + await store.handle( + .createNode(NodeDraft(title: "Reader", loopType: .turnBased, firstInstruction: "Work"))) return store } @@ -38,7 +46,8 @@ struct ArtifactoryTests { let store = await makeStore() let ids = nodeIDs(await store.graph) - await store.handle(.artifactoryPost(text: " issue #12 is mine ", topic: "Claims", from: ids[0])) + await store.handle( + .artifactoryPost(text: " issue #12 is mine ", topic: "Claims", from: ids[0])) let graph = await store.graph #expect(graph.artifactory.count == 1) @@ -67,8 +76,10 @@ struct ArtifactoryTests { let ids = nodeIDs(await store.graph) await store.handle(.artifactoryPost(text: " ", topic: nil, from: ids[0])) - await store.handle(.artifactoryPost(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: 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])) let graph = await store.graph @@ -158,7 +169,9 @@ struct ArtifactoryTests { await store.handle(.artifactoryWatch(on: false, topic: nil, from: ids[1])) await store.handle(.artifactoryPost(text: "again", topic: "build", from: ids[0])) - #expect(memory.value.filter { $0.0 == ids[1] && $0.1.contains("artifactory — new post") }.isEmpty) + #expect( + memory.value.filter { $0.0 == ids[1] && $0.1.contains("artifactory — new post") } + .isEmpty) } @Test @@ -171,7 +184,9 @@ struct ArtifactoryTests { await store.handle(.artifactoryPost(text: "a", topic: "auth", from: ids[0])) await store.handle(.artifactoryPost(text: "b", topic: nil, from: ids[0])) - #expect(memory.value.filter { $0.0 == ids[1] && $0.1.contains("artifactory — new post") }.count == 2) + #expect( + memory.value.filter { $0.0 == ids[1] && $0.1.contains("artifactory — new post") } + .count == 2) } @Test @@ -218,7 +233,8 @@ extension ArtifactoryTests { // MARK: - Shared-communication mirroring let store = await makeStore() let ids = nodeIDs(await store.graph) - await store.handle(.messageNode(ids[1], text: "the API changed under you", from: ids[0], followUp: nil)) + await store.handle( + .messageNode(ids[1], text: "the API changed under you", from: ids[0], followUp: nil)) let graph = await store.graph #expect(graph.artifactory.count == 1) @@ -334,3 +350,154 @@ extension ArtifactoryTests { // MARK: - Shared-communication mirroring #expect(graph.artifactory.isEmpty) } } + +/// 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 { + @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])) + + let graph = await store.graph + #expect(graph.artifactory.isEmpty) + #expect(errors.value.count == 1) + #expect(errors.value[0].contains("Artifactory is off")) + } + + @Test + func mirroredRecordTruncationRespectsTheBodyBound() async throws { + let store = await makeStore() + let ids = nodeIDs(await store.graph) + + await store.handle( + .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) + #expect(record.body.hasSuffix("…")) + } + + @Test + func liveIdleWatcherHearsThePostThroughTheDeliveryChannel() async { + let delivered = LockIsolated<[(UUID, String)]>([]) + let memory = LockIsolated<[(UUID, String)]>([]) + let store = await makeStore( + delivered: delivered, memory: memory, presence: .idle) + let ids = nodeIDs(await store.graph) + // 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(.artifactoryPost(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(!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") }) + } + + @Test + func compositeWorkerInheritsTheGateInsteadOfAMisleadingRefusal() async throws { + let errors = LockIsolated<[String]>([]) + let store = await makeStore(errors: errors) + var sub = LoopGraph(project: ProjectRef(path: "/tmp/sub", name: "sub")) + sub.nodes.append(LoopNode(title: "Worker", loopType: .turnBased, firstInstruction: "Work")) + await store.handle( + .createNode(NodeDraft(title: "Orchestrator", loopType: .composite, subGraph: sub))) + + // The draft re-identifies the sub-graph, so the worker is fetched from the + // stored composite, never from the value this test built. + let composite = try #require(await store.graph.nodes.first { $0.loopType == .composite }) + let workerID = try #require(composite.subGraph?.nodes.first?.id) + await store.handle( + .subGraphCommand( + nodeID: composite.id, + command: .artifactoryPost(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"]) + } + + @Test + func importedLoopStartsWithACleanCursor() async throws { + 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 + arriving.nodes.append(node) + + await store.handle( + .importNodes(GraphImportRequest(snapshot: arriving))) + + let graph = await store.graph + let imported = try #require(graph.nodes.first { $0.title == "Visitor" }) + #expect(imported.lastArtifactoryRead == nil) + } + + @Test + func watchOffWhenNotWatchingIsAHarmlessNoOp() async { + let errors = LockIsolated<[String]>([]) + let store = await makeStore(errors: errors) + let ids = nodeIDs(await store.graph) + + await store.handle(.artifactoryWatch(on: false, topic: nil, from: ids[0])) + + let graph = await store.graph + #expect(errors.value.isEmpty) + #expect(graph.nodes[id: ids[0]]?.artifactoryWatch == nil) + } + + @Test + func settingsRoundTripPinsTheRampBit() throws { + var settings = GraphcodeSettings() + settings.artifactoryEnabled = true + let data = try JSONEncoder().encode(settings) + #expect(try JSONDecoder().decode(GraphcodeSettings.self, from: data).artifactoryEnabled) + } + + @Test + func briefingAnnouncesTheBoardOnlyWhileItIsOn() { + let on = SessionBriefing.text( + projectPath: "/tmp/p", settings: GraphcodeSettings(artifactoryEnabled: 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 + // line where the section would have gone. + #expect(off?.contains("one-off.\n\n## Remembering across passes") == true) + } + + @Test + func wakeDigestRemindsAboutTheBoardOnlyWhileItIsOn() throws { + let baseURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("artifactory-tests-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: baseURL) } + let projectPath = "/tmp/digest" + let nodeID = UUID() + NodeMemory.append( + "something happened", projectPath: projectPath, nodeID: nodeID, baseURL: baseURL) + + let on = NodeMemory.writeWakeDigest( + projectPath: projectPath, nodeID: nodeID, artifactoryEnabled: true, baseURL: baseURL) + #expect(on != nil) + #expect(try String(contentsOf: try #require(on), encoding: .utf8).contains("Artifactory")) + + let off = NodeMemory.writeWakeDigest( + projectPath: projectPath, nodeID: nodeID, artifactoryEnabled: 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"))) + } +} From c1cdc7f477fd9979a747f0dbd6024912f1f4b516 Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 02:38:46 -0700 Subject: [PATCH 05/10] Read-side verbs: status board line, headlines+read, --json, --search, --mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storing was systematic; these make retrieval cheap without moving the daemon one inch closer to pushing — every verb stays pull, on need: - status renders 'artifactory: N posts, M unread for you' when the board has anything on it — the check rides along free, since the briefing already sends loops to status before claiming or creating work; a board that was never used renders exactly as before. The reader comes from ZMX_SESSION like every artifactory verb. - sync --headlines prints one triage line per unread post, and the new read prints one post in full: a loop joining after forty messages spends forty lines, not forty kilobytes. - sync --mark advances the cursor without printing the backlog — the 'start me from now' a mid-arrival loop needs — saying so in one line rather than succeeding silently. - --json on sync and list renders the same posts as the other syntax, plus the reader's cursor, so clients compute unread themselves. - --search on list filters by substring across author/topic/body — a list-side filter only, because marking unread mail read without showing it is the one way sync could lose mail. Briefing teaches the triage pattern. Full suite: 1378 tests / 144 suites pass; swift-format --strict clean on all touched directories. Signed-off-by: scgopi --- .../Sources/CLI/GraphcodeCommand.swift | 135 ++++++++++++++++-- .../Sources/Domain/SessionBriefing.swift | 2 + graphcode-cli/Sources/main.swift | 57 ++++++-- graphcode/Tests/ArtifactoryCommandTests.swift | 135 +++++++++++++++++- 4 files changed, 302 insertions(+), 27 deletions(-) diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index bb7b69d0..d9ef52c0 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -49,10 +49,18 @@ public enum GraphcodeCommand: Equatable, Sendable { /// like `sendMessage`, the sender comes from `ZMX_SESSION` at execution. case artifactoryPost(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. - case artifactorySync(projectPath: String) + /// 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. + case artifactorySync(projectPath: String, headlines: Bool, mark: Bool, json: 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) /// The whole board, read-only: no command reaches the daemon, no cursor moves. - case artifactoryList(projectPath: String) + /// `--search` filters by substring across author/topic/body; `--json` emits the + /// board machine-readably. + case artifactoryList(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?) @@ -86,10 +94,16 @@ public enum GraphcodeCommand: Equatable, Sendable { graphcode edge create [--kind ] [--condition ] graphcode artifactory post [--topic ] leave a note on the shared board for whoever comes next - graphcode artifactory sync - read your unread posts and mark the board read - graphcode artifactory list - the whole board, read-only — no cursor moves + graphcode artifactory sync [--headlines] [--mark] [--json] + read your unread posts and mark the board read; --headlines + prints one triage line each (deep-read with `read`), --mark + advances the cursor without printing the backlog, --json is + the machine-readable shape + graphcode artifactory 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 + filters by substring across author, topic and body graphcode artifactory watch [--topic ] [--off] have matching posts typed into this loop's session as they land; --off stops watching @@ -723,7 +737,9 @@ extension GraphcodeCommand { /// A graph rendered for a terminal. Node ids are shown in full because they're what /// every other subcommand takes as input — a truncated id would look tidier and be /// useless. - public static func render(_ graph: LoopGraph) -> String { + public static func render( + _ graph: LoopGraph, artifactoryReader readerID: UUID? = nil + ) -> String { var lines = ["\(graph.project.name) (\(graph.aggregateState))"] if graph.nodes.isEmpty { lines.append(" no loops yet") @@ -755,6 +771,11 @@ 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) { + lines.append(" \(boardLine)") + } return lines.joined(separator: "\n") } @@ -794,18 +815,35 @@ extension GraphcodeCommand { /// 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 daemon's cursor contract rests on, so the CLI's "unread" and the store's can - /// never disagree. + /// never disagree. `headlines` truncates each body to a triage line's worth (the + /// deep read is `artifactory 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( - _ graph: LoopGraph, unreadFor readerID: UUID? = nil + _ graph: LoopGraph, unreadFor readerID: UUID? = nil, headlines: Bool = false, + search: String? = nil ) -> String { - let posts: [ArtifactoryPost] + var posts: [ArtifactoryPost] if let readerID { posts = Artifactory.unread( in: graph.artifactory, since: graph.nodes[id: readerID]?.lastArtifactoryRead) } else { posts = graph.artifactory } + if let search, !search.isEmpty { + let needle = search.lowercased() + posts = posts.filter { + $0.body.lowercased().contains(needle) || $0.author.lowercased().contains(needle) + || $0.topic?.lowercased().contains(needle) == true + } + } guard !posts.isEmpty else { + if let search, !search.isEmpty { + return readerID == nil + ? "no posts match '\(search)'" + : "no unread posts match '\(search)'" + } return readerID == nil ? "the board is empty — post one: graphcode artifactory post " : "no unread posts" @@ -814,10 +852,53 @@ extension GraphcodeCommand { var lines = [ "\(graph.project.name) \(label): \(posts.count) post\(posts.count == 1 ? "" : "s")" ] - for post in posts { lines.append(" \(render(post))") } + for post in posts { + lines.append(headlines ? " \(renderHeadline(post))" : " \(render(post))") + } return lines.joined(separator: "\n") } + /// The board as one machine-readable object — the same posts `renderArtifactory` + /// would print, plus the reader's cursor so a client can compute unread itself. + /// `ArtifactoryPost` is already Codable; this is the same truth in the other syntax. + public static func renderArtifactoryJSON( + _ graph: LoopGraph, unreadFor readerID: UUID? = nil + ) -> String { + struct Board: Encodable { + var posts: [ArtifactoryPost] + var lastRead: Int? + } + let board = Board( + posts: + readerID.map { + Artifactory.unread(in: graph.artifactory, since: graph.nodes[id: $0]?.lastArtifactoryRead) + } ?? graph.artifactory, + lastRead: readerID.flatMap { graph.nodes[id: $0]?.lastArtifactoryRead }) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(board) else { return "{}" } + return String(decoding: data, as: UTF8.self) + } + + /// `status`'s one-line window onto the board: how many posts exist and — when the + /// caller is a loop with a cursor here — how many are unread for it. `nil` when the + /// board is empty, so a project that never touched the Artifactory 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( + _ graph: LoopGraph, readerID: UUID? = nil + ) -> String? { + guard !graph.artifactory.isEmpty else { return nil } + let total = graph.artifactory.count + let plural = total == 1 ? "" : "s" + guard let readerID else { return "artifactory: \(total) post\(plural)" } + let unread = Artifactory.unread( + in: graph.artifactory, since: graph.nodes[id: readerID]?.lastArtifactoryRead + ).count + return "artifactory: \(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 { @@ -829,6 +910,17 @@ extension GraphcodeCommand { 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 + /// 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 { + let full = render(post) + let budget = 80 + guard full.count > budget else { return full } + return String(full.prefix(budget)) + "…" + } + /// `artifactory 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 { @@ -962,12 +1054,25 @@ extension GraphcodeCommand { return .artifactoryPost(projectPath: path, topic: flags["topic"], text: text) case "sync": + try validateFlags(arguments, allowed: ["headlines", "mark", "json"]) + let flags = parseFlags(arguments) + return .artifactorySync( + projectPath: path, headlines: flags["headlines"] != nil, mark: flags["mark"] != nil, + json: flags["json"] != nil) + + case "read": try validateFlags(arguments, allowed: []) - return .artifactorySync(projectPath: path) + let raw = try take(&arguments, name: "post-id") + guard let postID = Int(raw) else { + throw ParseError.invalidValue(argument: "post-id", value: raw) + } + return .artifactoryRead(projectPath: path, postID: postID) case "list": - try validateFlags(arguments, allowed: []) - return .artifactoryList(projectPath: path) + try validateFlags(arguments, allowed: ["search", "json"]) + let flags = parseFlags(arguments) + return .artifactoryList( + projectPath: path, search: flags["search"], json: flags["json"] != nil) case "watch": try validateFlags(arguments, allowed: ["topic", "off"]) diff --git a/GraphcodeKit/Sources/Domain/SessionBriefing.swift b/GraphcodeKit/Sources/Domain/SessionBriefing.swift index e920fd96..c0c25d8c 100644 --- a/GraphcodeKit/Sources/Domain/SessionBriefing.swift +++ b/GraphcodeKit/Sources/Domain/SessionBriefing.swift @@ -97,6 +97,8 @@ public enum SessionBriefing { ```sh graphcode artifactory sync \(projectPath) # read what you have not seen, mark it read + graphcode artifactory sync \(projectPath) --headlines # many unread? triage first + graphcode artifactory read \(projectPath) # then read only those 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 diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index ff2c0aec..7bece5cc 100644 --- a/graphcode-cli/Sources/main.swift +++ b/graphcode-cli/Sources/main.swift @@ -101,6 +101,12 @@ do { } 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( + fromZmxSessionName: ProcessInfo.processInfo.environment["ZMX_SESSION"] ?? "") + /// Every mutating verb waits for the `.graphChanged` broadcast its own command caused, /// then prints the resulting graph. That's the daemon's only acknowledgement — it has no /// request/response correlation — and it doubles as useful output. @@ -119,7 +125,7 @@ func runAndPrintGraph(projectPath: String, _ commands: [DaemonCommand]) throws { guard !commands.isEmpty else { if case .graphChanged(let graph) = opened { - print(GraphcodeCommand.render(graph)) + print(GraphcodeCommand.render(graph, artifactoryReader: artifactoryReader)) } return } @@ -131,7 +137,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)) + print(GraphcodeCommand.render(graph, artifactoryReader: artifactoryReader)) } } @@ -256,7 +262,7 @@ do { } if case .errorOccurred(let message) = updateVerdict { fail(message) } if case .graphChanged(let graph) = updateVerdict { - print(GraphcodeCommand.render(graph)) + print(GraphcodeCommand.render(graph, artifactoryReader: artifactoryReader)) } case .promoteNode(let projectPath, let nodeID, let promotion): @@ -280,7 +286,7 @@ do { } if case .errorOccurred(let message) = promoteVerdict { fail(message) } if case .graphChanged(let graph) = promoteVerdict { - print(GraphcodeCommand.render(graph)) + print(GraphcodeCommand.render(graph, artifactoryReader: artifactoryReader)) } case .memoNode(let projectPath, let nodeID, let text): @@ -368,7 +374,7 @@ do { print(GraphcodeCommand.renderPosted(graph)) } - case .artifactorySync(let projectPath): + case .artifactorySync(let projectPath, let headlines, let mark, let json): // Attributed like `node send` — and required, the one place a artifactory 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 @@ -401,19 +407,50 @@ do { // anyway; fixing it properly means syncing to the highest *printed* id rather // than to latest, which nothing so far has needed. if case .graphChanged(let graph) = opened { - print(GraphcodeCommand.renderArtifactory(graph, unreadFor: reader)) + if json { + print(GraphcodeCommand.renderArtifactoryJSON(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. + print("marked read up to #\(graph.artifactory.last?.id ?? 0)") + } else { + print(GraphcodeCommand.renderArtifactory(graph, unreadFor: reader, headlines: headlines)) + } } - case .artifactoryList(let projectPath): + case .artifactoryRead(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. + try client.send(.openProject(path: projectPath)) + let read = try client.waitForEvent { + if case .graphChanged = $0 { return true } else { return false } + } + if case .graphChanged(let graph) = read { + guard let post = graph.artifactory.first(where: { $0.id == postID }) else { + fail( + "no post #\(postID) on this board — `graphcode artifactory list \(projectPath)` " + + "shows the ids that exist") + } + print(GraphcodeCommand.render(post)) + } + + case .artifactoryList(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. + // the board; `sync` is the loop's. `--search` filters what is shown, never what + // is remembered. try client.send(.openProject(path: projectPath)) let opened = try client.waitForEvent { if case .graphChanged = $0 { return true } else { return false } } if case .graphChanged(let graph) = opened { - print(GraphcodeCommand.renderArtifactory(graph)) + if json { + print(GraphcodeCommand.renderArtifactoryJSON(graph)) + } else { + print(GraphcodeCommand.renderArtifactory(graph, search: search)) + } } case .artifactoryWatch(let projectPath, let on, let topic): @@ -557,7 +594,7 @@ do { + "and \(bundle.graphSnapshot.edges.count) edge(s) with fresh identities" + (resumingSessions == 0 ? "" : "; \(resumingSessions) will resume their exported conversations")) - print(GraphcodeCommand.render(graph)) + print(GraphcodeCommand.render(graph, artifactoryReader: artifactoryReader)) } } } catch DaemonSocketClient.ClientError.timedOut { diff --git a/graphcode/Tests/ArtifactoryCommandTests.swift b/graphcode/Tests/ArtifactoryCommandTests.swift index 368a645a..9b336106 100644 --- a/graphcode/Tests/ArtifactoryCommandTests.swift +++ b/graphcode/Tests/ArtifactoryCommandTests.swift @@ -43,10 +43,10 @@ struct ArtifactoryCommandTests { func syncAndListTakeOnlyAProjectPath() throws { #expect( try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/x"]) - == .artifactorySync(projectPath: "/tmp/x")) + == .artifactorySync(projectPath: "/tmp/x", headlines: false, mark: false, json: false)) #expect( try GraphcodeCommand.parse(["artifactory", "list", "/tmp/x"]) - == .artifactoryList(projectPath: "/tmp/x")) + == .artifactoryList(projectPath: "/tmp/x", search: nil, json: false)) } @Test @@ -180,3 +180,134 @@ struct ArtifactoryCommandTests { } } } + +// MARK: Read-side verbs (status line, headlines, read, --json, --search, --mark) + +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 + graph.nodes.append(reader) + graph.artifactory = [ + ArtifactoryPost( + id: 1, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", + topic: nil, body: "already read"), + ArtifactoryPost( + id: 2, at: Date(timeIntervalSince1970: 1), authorID: UUID(), author: "Author", + topic: "build", body: String(repeating: "red ", count: 40)), + ArtifactoryPost( + id: 3, at: Date(timeIntervalSince1970: 2), authorID: nil, author: "a human", + topic: nil, body: "auth deadlock traced to token refresh"), + ] + return (graph, reader) +} + +@Test +func syncParsesItsReadModes() throws { + let plain = try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/x"]) + #expect(plain == .artifactorySync(projectPath: "/tmp/x", headlines: false, mark: false, json: false)) + + let all = try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/x", "--headlines", "--mark", "--json"]) + #expect( + all == .artifactorySync(projectPath: "/tmp/x", headlines: true, mark: true, json: true)) + + #expect { + try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/x", "--search", "x"]) + } throws: { error in + error as? GraphcodeCommand.ParseError == .unknownOption("--search") + } +} + +@Test +func readParsesAPostIDAndRejectsNonNumericOnes() throws { + #expect(try GraphcodeCommand.parse(["artifactory", "read", "/tmp/x", "7"]) + == .artifactoryRead(projectPath: "/tmp/x", postID: 7)) + + #expect { + try GraphcodeCommand.parse(["artifactory", "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"]) + } throws: { error in + error as? GraphcodeCommand.ParseError == .missingArgument("post-id") + } +} + +@Test +func listParsesSearchAndJSON() throws { + #expect( + try GraphcodeCommand.parse(["artifactory", "list", "/tmp/x", "--search", "auth", "--json"]) + == .artifactoryList(projectPath: "/tmp/x", search: "auth", json: true)) + #expect( + try GraphcodeCommand.parse(["artifactory", "list", "/tmp/x"]) + == .artifactoryList(projectPath: "/tmp/x", search: nil, json: false)) +} + +@Test +func headlinesCutBodiesToATriageLine() { + let (graph, reader) = boardWithPosts() + + let headlines = GraphcodeCommand.renderArtifactory(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) + #expect(full.contains("red red")) +} + +@Test +func searchFiltersWhatIsShownButNeverWhatIsRemembered() { + let (graph, reader) = boardWithPosts() + + // "deadlock" lives only in #3's body — note "auth" would have matched #2's + // author ("Author"), which is the filter doing its job, not a bug. + let filtered = GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id, search: "deadlock") + #expect(filtered.contains("#3")) + #expect(!filtered.contains("#2")) + + #expect( + GraphcodeCommand.renderArtifactory(graph, search: "nonesuch") + .contains("no posts match 'nonesuch'")) + #expect( + GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id, search: "nonesuch") + .contains("no unread posts match 'nonesuch'")) +} + +@Test +func jsonRendersTheSameTruthInOtherSyntax() throws { + struct Board: Decodable { + let posts: [ArtifactoryPost] + let lastRead: Int? + } + let (graph, reader) = boardWithPosts() + + let forReader = try #require( + GraphcodeCommand.renderArtifactoryJSON(graph, unreadFor: reader.id).data(using: .utf8)) + let decoded = try JSONDecoder().decode(Board.self, from: forReader) + #expect(decoded.lastRead == 1) + #expect(decoded.posts.map(\.id) == [2, 3]) + + let whole = try #require( + GraphcodeCommand.renderArtifactoryJSON(graph).data(using: .utf8)) + let everything = try JSONDecoder().decode(Board.self, from: whole) + #expect(everything.posts.count == 3) + #expect(everything.lastRead == nil) +} + +@Test +func statusLineCountsPostsAndUnreadOnlyWhenThereAreAny() { + let (graph, reader) = boardWithPosts() + + #expect( + GraphcodeCommand.renderArtifactoryStatusLine(graph, readerID: reader.id) + == "artifactory: 3 posts, 2 unread for you") + #expect( + GraphcodeCommand.renderArtifactoryStatusLine(graph) == "artifactory: 3 posts") + #expect(GraphcodeCommand.renderArtifactoryStatusLine(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")) +} From bd86716d45548291dfbb99c136a62b4b48de353a Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 02:39:27 -0700 Subject: [PATCH 06/10] swift-format: the read-side tests, formatted Signed-off-by: scgopi --- graphcode/Tests/ArtifactoryCommandTests.swift | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/graphcode/Tests/ArtifactoryCommandTests.swift b/graphcode/Tests/ArtifactoryCommandTests.swift index 9b336106..39e0846e 100644 --- a/graphcode/Tests/ArtifactoryCommandTests.swift +++ b/graphcode/Tests/ArtifactoryCommandTests.swift @@ -205,9 +205,12 @@ private func boardWithPosts() -> (LoopGraph, LoopNode) { @Test func syncParsesItsReadModes() throws { let plain = try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/x"]) - #expect(plain == .artifactorySync(projectPath: "/tmp/x", headlines: false, mark: false, json: false)) + #expect( + plain == .artifactorySync(projectPath: "/tmp/x", headlines: false, mark: false, json: false)) - let all = try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/x", "--headlines", "--mark", "--json"]) + let all = try GraphcodeCommand.parse([ + "artifactory", "sync", "/tmp/x", "--headlines", "--mark", "--json", + ]) #expect( all == .artifactorySync(projectPath: "/tmp/x", headlines: true, mark: true, json: true)) @@ -220,8 +223,9 @@ func syncParsesItsReadModes() throws { @Test func readParsesAPostIDAndRejectsNonNumericOnes() throws { - #expect(try GraphcodeCommand.parse(["artifactory", "read", "/tmp/x", "7"]) - == .artifactoryRead(projectPath: "/tmp/x", postID: 7)) + #expect( + try GraphcodeCommand.parse(["artifactory", "read", "/tmp/x", "7"]) + == .artifactoryRead(projectPath: "/tmp/x", postID: 7)) #expect { try GraphcodeCommand.parse(["artifactory", "read", "/tmp/x", "seven"]) @@ -241,10 +245,10 @@ func readParsesAPostIDAndRejectsNonNumericOnes() throws { func listParsesSearchAndJSON() throws { #expect( try GraphcodeCommand.parse(["artifactory", "list", "/tmp/x", "--search", "auth", "--json"]) - == .artifactoryList(projectPath: "/tmp/x", search: "auth", json: true)) + == .artifactoryList(projectPath: "/tmp/x", search: "auth", json: true)) #expect( try GraphcodeCommand.parse(["artifactory", "list", "/tmp/x"]) - == .artifactoryList(projectPath: "/tmp/x", search: nil, json: false)) + == .artifactoryList(projectPath: "/tmp/x", search: nil, json: false)) } @Test @@ -306,7 +310,9 @@ func statusLineCountsPostsAndUnreadOnlyWhenThereAreAny() { == "artifactory: 3 posts, 2 unread for you") #expect( GraphcodeCommand.renderArtifactoryStatusLine(graph) == "artifactory: 3 posts") - #expect(GraphcodeCommand.renderArtifactoryStatusLine(LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x"))) == nil) + #expect( + GraphcodeCommand.renderArtifactoryStatusLine( + 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")) From 0c75095dd8126adddf0a0b94fcf1b7f4b9a96f3f Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 02:58:51 -0700 Subject: [PATCH 07/10] Read-side review: status line everywhere, json+search compose, edge cases pinned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review verdict was mergeable; these are its fixes: - The status board line now renders on the no-loops path too — a board with human posts outlives every loop on it, and 'no loops yet' was hiding it. - list --search --json composes: the filter threads into the JSON, so the combination can no longer quietly return the whole board. - status 'unread for you' is claimed only for a reader this graph knows — the daemon refuses sync for a foreign identity, so the line no longer claims unread for one either. - read rejects non-positive ids at parse ('-7' is a typo, not a post). - --json dates are ISO-8601, pinned by a decoding-strategy test so the wire format cannot drift silently. - Help documents the sync flags' precedence (--json > --mark > --headlines); 'marked read up to #0' on an empty board is now a sentence; headline truncation flattens newlines; 'a artifactory' typo. - Tests: exact-80 headline boundary, never-touched board renders without the line, nodes-empty+human-posts state, json+search composition, foreign-reader count. Full suite: 1384 tests / 144 suites pass; swift-format --strict clean. Signed-off-by: scgopi --- .../Sources/CLI/GraphcodeCommand.swift | 56 ++++++++--- graphcode-cli/Sources/main.swift | 13 ++- graphcode/Tests/ArtifactoryCommandTests.swift | 93 ++++++++++++++++++- 3 files changed, 142 insertions(+), 20 deletions(-) diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index d9ef52c0..5cb68a3b 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -98,7 +98,9 @@ public enum GraphcodeCommand: Equatable, Sendable { read your unread posts and mark the board read; --headlines prints one triage line each (deep-read with `read`), --mark advances the cursor without printing the backlog, --json is - the machine-readable shape + the machine-readable shape. Combined, the output wins in the + order --json > --mark > --headlines; the cursor advances + whichever flags you pass graphcode artifactory read one post in full — the deep-read half of --headlines graphcode artifactory list [--search ] [--json] @@ -743,6 +745,12 @@ extension GraphcodeCommand { var lines = ["\(graph.project.name) (\(graph.aggregateState))"] if graph.nodes.isEmpty { lines.append(" no loops yet") + // 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) { + lines.append(" \(boardLine)") + } return lines.joined(separator: "\n") } for node in graph.nodes { @@ -859,23 +867,36 @@ extension GraphcodeCommand { } /// The board as one machine-readable object — the same posts `renderArtifactory` - /// would print, plus the reader's cursor so a client can compute unread itself. - /// `ArtifactoryPost` is already Codable; this is the same truth in the other syntax. + /// 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( - _ graph: LoopGraph, unreadFor readerID: UUID? = nil + _ graph: LoopGraph, unreadFor readerID: UUID? = nil, search: String? = nil ) -> String { struct Board: Encodable { var posts: [ArtifactoryPost] var lastRead: Int? } - let board = Board( - posts: - readerID.map { - Artifactory.unread(in: graph.artifactory, since: graph.nodes[id: $0]?.lastArtifactoryRead) - } ?? graph.artifactory, - lastRead: readerID.flatMap { graph.nodes[id: $0]?.lastArtifactoryRead }) + var posts: [ArtifactoryPost] + if let readerID { + posts = Artifactory.unread( + in: graph.artifactory, since: graph.nodes[id: readerID]?.lastArtifactoryRead) + } else { + posts = graph.artifactory + } + if let search, !search.isEmpty { + let needle = search.lowercased() + posts = posts.filter { + $0.body.lowercased().contains(needle) || $0.author.lowercased().contains(needle) + || $0.topic?.lowercased().contains(needle) == true + } + } + let board = Board(posts: posts, lastRead: readerID.flatMap { graph.nodes[id: $0]?.lastArtifactoryRead }) let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] + encoder.dateEncodingStrategy = .iso8601 guard let data = try? encoder.encode(board) else { return "{}" } return String(decoding: data, as: UTF8.self) } @@ -892,7 +913,12 @@ extension GraphcodeCommand { guard !graph.artifactory.isEmpty else { return nil } let total = graph.artifactory.count let plural = total == 1 ? "" : "s" - guard let readerID else { return "artifactory: \(total) post\(plural)" } + // "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)" + } let unread = Artifactory.unread( in: graph.artifactory, since: graph.nodes[id: readerID]?.lastArtifactoryRead ).count @@ -915,7 +941,9 @@ extension GraphcodeCommand { /// 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 { - let full = render(post) + // 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: " ") let budget = 80 guard full.count > budget else { return full } return String(full.prefix(budget)) + "…" @@ -1063,7 +1091,9 @@ extension GraphcodeCommand { case "read": try validateFlags(arguments, allowed: []) let raw = try take(&arguments, name: "post-id") - guard let postID = Int(raw) else { + // 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 runtime lookup. + guard let postID = Int(raw), postID >= 1 else { throw ParseError.invalidValue(argument: "post-id", value: raw) } return .artifactoryRead(projectPath: path, postID: postID) diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index 7bece5cc..c985f7e3 100644 --- a/graphcode-cli/Sources/main.swift +++ b/graphcode-cli/Sources/main.swift @@ -375,7 +375,7 @@ do { } case .artifactorySync(let projectPath, let headlines, let mark, let json): - // Attributed like `node send` — and required, the one place a artifactory verb + // Attributed like `node send` — and required, the one place an artifactory 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`. @@ -413,7 +413,11 @@ do { // 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. - print("marked read up to #\(graph.artifactory.last?.id ?? 0)") + if let latest = graph.artifactory.last?.id, latest > 0 { + print("marked read up to #\(latest)") + } else { + print("marked read — the board is empty") + } } else { print(GraphcodeCommand.renderArtifactory(graph, unreadFor: reader, headlines: headlines)) } @@ -422,8 +426,7 @@ do { case .artifactoryRead(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. - try client.send(.openProject(path: projectPath)) + // per post a loop actually decides to care about. try client.send(.openProject(path: projectPath)) let read = try client.waitForEvent { if case .graphChanged = $0 { return true } else { return false } } @@ -447,7 +450,7 @@ do { } if case .graphChanged(let graph) = opened { if json { - print(GraphcodeCommand.renderArtifactoryJSON(graph)) + print(GraphcodeCommand.renderArtifactoryJSON(graph, search: search)) } else { print(GraphcodeCommand.renderArtifactory(graph, search: search)) } diff --git a/graphcode/Tests/ArtifactoryCommandTests.swift b/graphcode/Tests/ArtifactoryCommandTests.swift index 39e0846e..3a275828 100644 --- a/graphcode/Tests/ArtifactoryCommandTests.swift +++ b/graphcode/Tests/ArtifactoryCommandTests.swift @@ -290,13 +290,15 @@ func jsonRendersTheSameTruthInOtherSyntax() throws { let forReader = try #require( GraphcodeCommand.renderArtifactoryJSON(graph, unreadFor: reader.id).data(using: .utf8)) - let decoded = try JSONDecoder().decode(Board.self, from: forReader) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(Board.self, from: forReader) #expect(decoded.lastRead == 1) #expect(decoded.posts.map(\.id) == [2, 3]) let whole = try #require( GraphcodeCommand.renderArtifactoryJSON(graph).data(using: .utf8)) - let everything = try JSONDecoder().decode(Board.self, from: whole) + let everything = try decoder.decode(Board.self, from: whole) #expect(everything.posts.count == 3) #expect(everything.lastRead == nil) } @@ -317,3 +319,90 @@ func statusLineCountsPostsAndUnreadOnlyWhenThereAreAny() { let rendered = GraphcodeCommand.render(graph, artifactoryReader: reader.id) #expect(rendered.contains("artifactory: 3 posts, 2 unread for you")) } + +// MARK: Read-side review round (status-line blast radius, json+search, boundaries) + +@Test +func statusLineSurvivesAnEmptyNodeGraphWithHumanPosts() { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + graph.artifactory = [ + ArtifactoryPost( + 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")) +} + +@Test +func neverTouchedBoardRendersExactlyAsBeforeTheArtifactory() { + 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:")) + #expect(!rendered.contains("\n edges:")) +} + +@Test +func foreignReaderGetsThePlainCountLikeAHuman() { + let (graph, _) = boardWithPosts() + // 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") +} + +@Test +func listJSONHonorsTheSearchFilter() throws { + struct Board: Decodable { + let posts: [ArtifactoryPost] + } + let (graph, _) = boardWithPosts() + + let filtered = try #require( + GraphcodeCommand.renderArtifactoryJSON(graph, search: "deadlock").data(using: .utf8)) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + #expect(try decoder.decode(Board.self, from: filtered).posts.map(\.id) == [3]) +} + +@Test +func jsonDatesAreISOTwo8601NotTheEncoderDefault() throws { + struct Board: Decodable { + let posts: [ArtifactoryPost] + } + let (graph, _) = boardWithPosts() + let data = try #require( + GraphcodeCommand.renderArtifactoryJSON(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. + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(Board.self, from: data) + #expect(decoded.posts.count == 3) +} + +@Test +func headlineTruncatesOnlyPastTheBoundaryAndStaysOneLine() { + // Rendered prefix up to the body: "#1 from a human at — " — build the + // 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: "") + ) + let exact = ArtifactoryPost( + id: 1, at: at, authorID: nil, author: "a human", topic: nil, + body: String(repeating: "a", count: 80 - prefix.count)) + let over = ArtifactoryPost( + id: 1, at: at, authorID: nil, author: "a human", topic: nil, + body: String(repeating: "a", count: 81 - prefix.count)) + + #expect(GraphcodeCommand.renderHeadline(exact) == GraphcodeCommand.render(exact)) + #expect(GraphcodeCommand.renderHeadline(over).hasSuffix("…")) + #expect(!GraphcodeCommand.renderHeadline(over).contains("\n")) +} From 79087bbab5d816ca10c7dad15f469c1094b6aa1f Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 02:59:25 -0700 Subject: [PATCH 08/10] swift-format: one long line, wrapped Signed-off-by: scgopi --- GraphcodeKit/Sources/CLI/GraphcodeCommand.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index 5cb68a3b..1dd58a18 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -893,7 +893,8 @@ extension GraphcodeCommand { || $0.topic?.lowercased().contains(needle) == true } } - let board = Board(posts: posts, lastRead: readerID.flatMap { graph.nodes[id: $0]?.lastArtifactoryRead }) + let lastRead = readerID.flatMap { graph.nodes[id: $0]?.lastArtifactoryRead } + let board = Board(posts: posts, lastRead: lastRead) let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] encoder.dateEncodingStrategy = .iso8601 From 13cbb03c9a9c88fb2d7aeb7a14587696be34ae73 Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 05:01:38 -0700 Subject: [PATCH 09/10] Artifactory: separate budgets, notes that outlive their authors, a rail section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An independent review of #229 against the aim it was drawn from (the shared board agents turned Artifactory into, in the OpenAI/Hugging Face incident) found the mechanism faithful — unaddressed, discoverable, ambient — and two properties that carried the aim broken. Measured, not inferred: probes against a real GraphStore. - Notes and mirrored records prune on separate budgets. They shared one 200-slot pool, so a graph that merely *talked* wiped its own board: 200 `node send`s evicted the only real note on it and left 200 transport receipts. `ArtifactoryPost.Kind` splits them; maxNotes 200, maxRecords 50, pruned independently. Chatter now fills its own quota and stops. - Deleting a loop keeps its notes and takes only the handle. Erasing them retracted things peers had already acted on, and contradicted the module's own promise that posts outlive their authors — the article's whole point being that a civilisation inherited the research of predecessors who died. authorID goes (nothing addresses a loop that is gone), the byline says "(deleted)", the body stays. - `sync` triages itself. A loop cannot know how much mail it has before reading it, and one born after a busy week inherited the whole board: measured at 200 notes, ~180 KB, ~45k tokens on its first sync. Past 12 posts or 4 KiB it prints headlines and says so; `--full` overrides. - Resolution asks a loop to leave a note. Every other affordance was read-side, so nothing ever pulled a write. Now the one moment a loop knows what it learned also asks it to post — on failure too, and for every loop type, because a dead end is the finding a successor pays for twice. Folded into the skill-distillation ask so a resolving goal loop is interrupted once, not twice. - ArtifactorySection: the board in the workspace rail, a peer of SUMMARY and BOARD. A coordination channel a supervisor never sees is the failure mode the incident turned on, and until now reading one meant a CLI verb nobody had been told about. Notes newest-at-the-foot with the summary's own SINCE YOU LOOKED rule, mirrored records folded to a rollup, and a composer that posts as "a human" — the app carries no ZMX_SESSION, which is exactly what a person addressing the whole graph is. Not fixed, and stated rather than papered over: attribution is derived from the caller's environment, so anything that can reach the daemon socket can post as "a human" or as another loop. That is the trust model `node send` and `node memo` already have; closing it needs peer credentials on the socket, which no graphcode surface has. Documented on `authorID`. Full suite: 1399 tests / 145 suites pass (was 1384/144); swiftlint 0 errors, swift-format --strict clean. Signed-off-by: scgopi Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019A6NULwEiBXEdRXwEKKcRH --- ArtifactoryKit/Sources/Artifactory.swift | 105 +++++- .../Sources/CLI/GraphcodeCommand.swift | 39 +- GraphcodeKit/Sources/Domain/LoopGraph.swift | 3 +- .../Sources/Domain/SessionBriefing.swift | 11 +- GraphcodeKit/Sources/GraphStore.swift | 39 +- .../Sources/Sessions/MessageBus.swift | 38 +- graphcode-cli/Sources/main.swift | 10 +- .../Sources/Features/App/AppFeature.swift | 12 + .../LoopWorkspace/ArtifactorySection.swift | 356 ++++++++++++++++++ .../LoopWorkspace/LoopWorkspaceFeature.swift | 16 + .../LoopWorkspace/LoopWorkspaceRail.swift | 38 +- .../LoopWorkspace/LoopWorkspaceView.swift | 12 +- graphcode/Tests/ArtifactoryBudgetTests.swift | 279 ++++++++++++++ graphcode/Tests/ArtifactoryCommandTests.swift | 11 +- graphcode/Tests/ArtifactoryTests.swift | 30 +- 15 files changed, 940 insertions(+), 59 deletions(-) create mode 100644 graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift create mode 100644 graphcode/Tests/ArtifactoryBudgetTests.swift diff --git a/ArtifactoryKit/Sources/Artifactory.swift b/ArtifactoryKit/Sources/Artifactory.swift index 677e90b4..1a8eccac 100644 --- a/ArtifactoryKit/Sources/Artifactory.swift +++ b/ArtifactoryKit/Sources/Artifactory.swift @@ -6,20 +6,42 @@ import Foundation /// while the Artifactory 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, -/// deletion's siblings, and the daemon's own restarts. +/// survive their authors: they live on the graph itself, outlasting resolution, the +/// author's deletion, and the daemon's own restarts. /// /// 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 { + /// 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 + /// graph something; a record is the board keeping the receipt for a message that was + /// already delivered elsewhere. They were pruned from one pool once, and a graph that + /// merely *talked* — two hundred `node send`s, which a ten-way fanout reaches without + /// 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 + } + /// Position in the board's sequence, 1-based. The unread cursor is this number, so /// ids must only ever grow — they are assigned by `GraphStore` from the current /// maximum, never from the post count, which pruning would shrink. public let id: Int public let at: Date /// The posting loop's node id, when a loop posted it. `nil` from a human's shell - /// (`$ZMX_SESSION` absent), which is how a person talks to the whole graph at once. + /// (`$ZMX_SESSION` absent), which is how a person talks to the whole graph at once, + /// and `nil` again once the authoring loop is deleted — the note stays, the handle + /// to reply to it does not. + /// + /// Derived from the caller's environment, so it is an attribution and not an + /// authentication: anything that can reach the daemon socket can claim any identity + /// here, exactly as it can for `node send` and `node memo`. Closing that would take + /// peer credentials on the socket, which no graphcode surface has yet. public let authorID: UUID? /// The author's loop title, or "a human" — what a reader sees; the id above is /// what it uses to reply in person with `node send`. @@ -29,6 +51,9 @@ public struct ArtifactoryPost: Codable, Equatable, Identifiable, Sendable { /// way; a watcher with no topic hears everything. public let topic: String? public let body: String + /// Which budget prunes this post. Absent from graphs saved before records had their + /// own quota, where everything on the board was a note. + public let kind: Kind /// Cached formatter for CLI rendering — one `DateFormatter` per process rather than /// per post, and a fixed `dateFormat` with a pinned locale rather than @@ -42,7 +67,8 @@ public struct ArtifactoryPost: Codable, Equatable, Identifiable, Sendable { }() public init( - id: Int, at: Date, authorID: UUID?, author: String, topic: String?, body: String + id: Int, at: Date, authorID: UUID?, author: String, topic: String?, body: String, + kind: Kind = .note ) { self.id = id self.at = at @@ -50,6 +76,37 @@ public struct ArtifactoryPost: Codable, Equatable, Identifiable, Sendable { self.author = author self.topic = topic self.body = body + self.kind = kind + } + + private enum CodingKeys: String, CodingKey { + case id, at, authorID, author, topic, body, kind + } + + /// Hand-written for the reason `LoopNode`'s is: a board saved before `kind` existed + /// must decode rather than take the whole graph down with it, and everything on such + /// a board was posted deliberately. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + at = try container.decode(Date.self, forKey: .at) + authorID = try container.decodeIfPresent(UUID.self, forKey: .authorID) + 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 + } + + /// The same post with its author's handle gone — what deleting a loop leaves behind. + /// + /// A note is addressed to *other* loops, so erasing it on delete retracts something + /// 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( + id: id, at: at, authorID: nil, author: "\(author) (deleted)", topic: topic, + body: body, kind: kind) } /// The bound that keeps "check the board" cheap. A note that cannot fit in a @@ -72,11 +129,17 @@ 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 posts a board keeps. The oldest fall off first: an Artifactory is a + /// 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 maxPosts = 200 + 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 /// 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 @@ -85,6 +148,24 @@ public enum Artifactory { (posts.map(\.id).max() ?? 0) + 1 } + /// Past this many unread posts, or this many bytes of them, `sync` triages itself + /// down to one line per post rather than printing every body. + /// + /// 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 + /// 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 { + 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? @@ -93,10 +174,14 @@ public enum Artifactory { return posts.filter { $0.id > lastRead } } - /// A board that grew past `maxPosts`, oldest first gone. Applied by the store on - /// every post so no caller can forget. + /// 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] { - guard posts.count > maxPosts else { return posts } - return Array(posts.suffix(maxPosts)) + let notes = posts.filter { $0.kind == .note } + let records = posts.filter { $0.kind == .record } + guard notes.count > maxNotes || records.count > maxRecords else { return posts } + let kept = Set( + (notes.suffix(maxNotes) + records.suffix(maxRecords)).map(\.id)) + return posts.filter { kept.contains($0.id) } } } diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index 1dd58a18..8d7f9ab5 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -52,8 +52,10 @@ public enum GraphcodeCommand: Equatable, Sendable { /// 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. - case artifactorySync(projectPath: String, headlines: Bool, mark: Bool, json: Bool) + /// `--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( + 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) @@ -94,13 +96,15 @@ public enum GraphcodeCommand: Equatable, Sendable { graphcode edge create [--kind ] [--condition ] graphcode artifactory post [--topic ] leave a note on the shared board for whoever comes next - graphcode artifactory sync [--headlines] [--mark] [--json] - read your unread posts and mark the board read; --headlines - prints one triage line each (deep-read with `read`), --mark + graphcode artifactory sync [--headlines] [--full] [--mark] [--json] + read your unread posts and mark the board read. A large + backlog prints as headlines on its own and says so — + --full insists on every body, --headlines insists on + triage lines (deep-read either with `read`). --mark advances the cursor without printing the backlog, --json is the machine-readable shape. Combined, the output wins in the - order --json > --mark > --headlines; the cursor advances - whichever flags you pass + order --json > --mark > --headlines > --full; the cursor + advances whichever flags you pass graphcode artifactory read one post in full — the deep-read half of --headlines graphcode artifactory list [--search ] [--json] @@ -830,7 +834,7 @@ extension GraphcodeCommand { /// way this verb could lose mail. public static func renderArtifactory( _ graph: LoopGraph, unreadFor readerID: UUID? = nil, headlines: Bool = false, - search: String? = nil + search: String? = nil, autoTriage: Bool = false ) -> String { var posts: [ArtifactoryPost] if let readerID { @@ -856,12 +860,21 @@ extension GraphcodeCommand { ? "the board is empty — post one: graphcode artifactory 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" - var lines = [ + 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) " + } + var lines = [header] for post in posts { - lines.append(headlines ? " \(renderHeadline(post))" : " \(render(post))") + lines.append(headlines || triaged ? " \(renderHeadline(post))" : " \(render(post))") } return lines.joined(separator: "\n") } @@ -1083,11 +1096,11 @@ extension GraphcodeCommand { return .artifactoryPost(projectPath: path, topic: flags["topic"], text: text) case "sync": - try validateFlags(arguments, allowed: ["headlines", "mark", "json"]) + try validateFlags(arguments, allowed: ["headlines", "mark", "json", "full"]) let flags = parseFlags(arguments) return .artifactorySync( projectPath: path, headlines: flags["headlines"] != nil, mark: flags["mark"] != nil, - json: flags["json"] != nil) + json: flags["json"] != nil, full: flags["full"] != nil) case "read": try validateFlags(arguments, allowed: []) diff --git a/GraphcodeKit/Sources/Domain/LoopGraph.swift b/GraphcodeKit/Sources/Domain/LoopGraph.swift index 5046a55c..47761a55 100644 --- a/GraphcodeKit/Sources/Domain/LoopGraph.swift +++ b/GraphcodeKit/Sources/Domain/LoopGraph.swift @@ -21,7 +21,8 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { public var nodes: IdentifiedArrayOf public var edges: IdentifiedArrayOf /// The project's Artifactory — every post any loop has dropped onto the shared board, - /// oldest first, capped at `Artifactory.maxPosts`. Kept on the graph rather than in a + /// oldest first, notes and mirrored records each capped on their own budget + /// (`Artifactory.maxNotes`, `Artifactory.maxRecords`). Kept on the graph rather than in a /// side store so it inherits for free everything graph state already has: one /// writer (the daemon), atomic persistence beside the graph file, a snapshot in /// every `.graphChanged` (which is how the CLI reads it — no second read path), and diff --git a/GraphcodeKit/Sources/Domain/SessionBriefing.swift b/GraphcodeKit/Sources/Domain/SessionBriefing.swift index c0c25d8c..5772208e 100644 --- a/GraphcodeKit/Sources/Domain/SessionBriefing.swift +++ b/GraphcodeKit/Sources/Domain/SessionBriefing.swift @@ -97,8 +97,7 @@ public enum SessionBriefing { ```sh graphcode artifactory sync \(projectPath) # read what you have not seen, mark it read - graphcode artifactory sync \(projectPath) --headlines # many unread? triage first - graphcode artifactory read \(projectPath) # then read only those in full + 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 @@ -107,13 +106,17 @@ public enum SessionBriefing { Post decisions made, dead ends hit, claims staked ("I'm taking issue #12") — a note for a peer, not a transcript. Sync before you rely on nobody having got there first, and watch a topic when you want the board to come to you. + A big backlog prints as one line per post and says so; `read ` then + spends context only on the ones that turned out to matter. The board also keeps the record for you: every direct message, message-edge delivery, and handoff (topics `direct` and `handoff`) is mirrored onto it automatically, so a loop that joins mid-flight can read what was already said. Those mirrored records are the record, not the delivery — they never ring a - watcher, so watching only those topics stays silent. Your posts stay on the - board after you resolve; they go only if your loop is deleted. + 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. """ : "" return """ diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index feb27712..ca26b4cd 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -1413,7 +1413,8 @@ public actor GraphStore { /// memory to), so mirroring must not ring the watchers, or a busy graph would have /// every direct message waking every listener on top of its real delivery. /// Gated like every board write; body carries the target so a reader can tell a - /// note to the room from a note to a peer. + /// note to the room from a note to a peer. Written as `.record`, which is what keeps + /// a talkative graph inside its own budget instead of evicting the notes. private func recordArtifactoryCommunication( from senderID: UUID?, to target: LoopNode, text: String, topic: String ) { @@ -1428,7 +1429,7 @@ public actor GraphStore { } let post = ArtifactoryPost( id: Artifactory.nextID(after: graph.artifactory), at: Date(), authorID: senderID, - author: sender, topic: topic, body: body) + author: sender, topic: topic, body: body, kind: .record) graph.artifactory = Artifactory.pruned(graph.artifactory + [post]) } @@ -1594,12 +1595,18 @@ public actor GraphStore { // memory goes the same way — a log for a loop that no longer exists is litter. terminateSession(node) onRemoveMemory?(node.id) - // And its artifactory posts: delete is the one irreversible action in graphcode, - // and the confirmation that covers edges, session, and memory covers the loop's - // board record too. Posts where this loop was only the *recipient* stay — those - // are the other side's record of a communication that happened. A loop that wants - // its notes to survive should be stopped, not deleted. - graph.artifactory.removeAll { $0.authorID == node.id } + // Its artifactory 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 { + continue + } + graph.artifactory[index] = post.withAuthorDeleted() + } // A composite's workers live in its sub-graph, on this node rather than in // `graph.nodes` — the same blind spot `requestStop` covers when stopping, and the @@ -1746,14 +1753,20 @@ public actor GraphStore { setNodeState(nodeID, succeeded ? .succeeded : .failed) cancelGoalPoller(nodeID) recordMemory(nodeID, "resolved: \(succeeded ? "succeeded" : "failed")") - // Skill distillation rides resolution: a goal loop that just succeeded is the one - // agent holding a proven method in context. Its own queue rather than + // Two asks ride resolution, in one interruption. Skill distillation: a goal loop + // that just succeeded is the one agent holding a proven method in context, and + // success is load-bearing there — a failed loop's method is not a recipe. The + // board post: whatever this loop learned, including *why it failed*, which is the + // finding a successor would otherwise pay for twice. Its own queue rather than // `pendingNudges`, because the state written above is exactly what // `MessageBus.deliverability` reads — a resolved node is "not live" to the graph // while its PTY is still very much there (the `requestStop` ordering lesson). - // Success only: a failed loop's method is not a recipe. - if succeeded, sessionMayStillBeLive, node.loopType == .goalBased { - pendingResolutionNudges.append((nodeID, MessageBus.distillSkillRequest)) + if sessionMayStillBeLive, + let ask = MessageBus.resolutionAsk( + distillSkill: succeeded && node.loopType == .goalBased, + artifactoryProjectPath: artifactoryIsOn() ? graph.project.path : nil) + { + pendingResolutionNudges.append((nodeID, ask)) } fireOutgoingEdges(from: nodeID, sourceSucceeded: succeeded) } diff --git a/GraphcodeKit/Sources/Sessions/MessageBus.swift b/GraphcodeKit/Sources/Sessions/MessageBus.swift index 3790680f..281ec967 100644 --- a/GraphcodeKit/Sources/Sessions/MessageBus.swift +++ b/GraphcodeKit/Sources/Sessions/MessageBus.swift @@ -78,13 +78,47 @@ public enum MessageBus { /// /// "If" is load-bearing: a one-off fix distilled into a skill is library pollution, /// and the judgement of reusability belongs to the agent that did the work. - public static let distillSkillRequest = - "[graphcode] Goal met. Before you finish: if the method that got you here would be " + public static let distillSkillRequest = prefix + distillSkillBody + + private static let prefix = "[graphcode] " + + private static let distillSkillBody = + "Goal met. Before you finish: if the method that got you here would be " + "reusable by another loop in this project, distill it into a project skill in " + "your backend's native format (for Claude Code: .claude/skills//SKILL.md, " + "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*. + /// + /// Every other artifactory 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. + /// + /// 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 { + "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." + } + + /// 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? + ) -> String? { + var parts: [String] = [] + if distillSkill { parts.append(distillSkillBody) } + if let path = artifactoryProjectPath { parts.append(artifactoryPostBody(projectPath: path)) } + guard !parts.isEmpty else { return nil } + return prefix + parts.joined(separator: " ") + } + /// What actually gets typed into the target. The edge's transform decides the content; /// a `.script` transform runs to produce it, which is docs/08's "a script is cheaper /// than reasoning through the steps every time" applied to a hand-off. diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index c985f7e3..8516b3f9 100644 --- a/graphcode-cli/Sources/main.swift +++ b/graphcode-cli/Sources/main.swift @@ -374,7 +374,7 @@ do { print(GraphcodeCommand.renderPosted(graph)) } - case .artifactorySync(let projectPath, let headlines, let mark, let json): + case .artifactorySync(let projectPath, let headlines, let mark, let json, let full): // Attributed like `node send` — and required, the one place an artifactory 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 @@ -419,7 +419,13 @@ do { print("marked read — the board is empty") } } else { - print(GraphcodeCommand.renderArtifactory(graph, unreadFor: reader, headlines: headlines)) + // `autoTriage` unless the caller said which way they want it: a loop cannot + // know how much mail it has before reading it, and the first sync of a loop + // born after a busy week is the whole board. + print( + GraphcodeCommand.renderArtifactory( + graph, unreadFor: reader, headlines: headlines, + autoTriage: !headlines && !full)) } } diff --git a/graphcode/Sources/Features/App/AppFeature.swift b/graphcode/Sources/Features/App/AppFeature.swift index 1da5e902..aabcfe4a 100644 --- a/graphcode/Sources/Features/App/AppFeature.swift +++ b/graphcode/Sources/Features/App/AppFeature.swift @@ -587,6 +587,18 @@ struct AppFeature { state.selectedProjectPath = path return .none + // 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)): + 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))) + } + case .openLoop(.railTargetTapped(let nodeID)): guard let path = state.openLoop?.projectPath else { return .none } return .send(.projects(.element(id: path, action: .nodeTapped(nodeID)))) diff --git a/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift new file mode 100644 index 00000000..c6ff3b7c --- /dev/null +++ b/graphcode/Sources/Features/LoopWorkspace/ArtifactorySection.swift @@ -0,0 +1,356 @@ +import ArtifactoryKit +import GraphcodeKit +import SwiftUI + +/// What the rail needs to know about the board without building a view to find out. +enum ArtifactoryPresentation { + /// 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. + /// + /// 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 + } + + /// 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 } + } + + /// The mirrored direct messages and handoffs. Kept apart from the notes because they + /// are receipts for deliveries that already happened, not something written to be + /// read here. + static func records(in graph: LoopGraph) -> [ArtifactoryPost] { + graph.artifactory.filter { $0.kind == .record } + } + + /// How many notes this loop's cursor has not covered. Records are excluded: they are + /// folded away by default, and a badge counting mail nobody is being shown is a badge + /// that cannot be cleared. + static func unreadNoteCount(graph: LoopGraph, node: LoopNode) -> Int { + Artifactory.unread(in: notes(in: graph), since: node.lastArtifactoryRead).count + } +} + +/// The Artifactory in the workspace rail — a peer of `LoopSummarySection` and +/// `SummaryBoardSection`, and the one place a human meets the board without a shell. +/// +/// The board is how loops leave notes for whoever comes next, and until this section +/// existed the only way to read one was a CLI verb nobody had been told about. That is +/// the whole reason it is here rather than behind a menu: a coordination channel a +/// supervisor never sees is the failure mode, not a missing convenience. +struct ArtifactorySection: View { + let node: LoopNode + let graph: LoopGraph + let isFolded: Bool + 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 + /// 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. + @State private var showsRecords = 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 unread: Int { + ArtifactoryPresentation.unreadNoteCount(graph: graph, node: node) + } + + /// The id the unread rule is drawn above — the first note this loop's cursor has not + /// covered. `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 + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + header + if isFolded { + foldedLine + } else { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 11) { + recordsRollup + ForEach(notes) { post in + if post.id == firstUnreadID { sinceYouLooked } + postRow(post) + } + if isComposing { composer } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .scrollBounceBehavior(.basedOnSize) + .defaultScrollAnchor(.bottom) + .frame(minHeight: 90, maxHeight: .infinity) + } + Rectangle().fill(.white.opacity(0.07)).frame(height: 1) + } + } + + private var unreadIDs: Set { + Set(notes.suffix(unread).map(\.id)) + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: 7) { + Text("ARTIFACTORY") + .font(.system(size: 10.5, weight: .bold)) + .tracking(0.63) + .foregroundStyle(.white.opacity(0.5)) + Spacer(minLength: 0) + if unread > 0 { + Text("\(unread) NEW") + .font(.system(size: 9.5, weight: .bold)) + .tracking(0.38) + .foregroundStyle(Color(red: 0.549, green: 0.773, blue: 1.0)) + .padding(.horizontal, 5) + .frame(height: 14) + .background( + Theme.paneFocusTint.opacity(0.22), in: RoundedRectangle(cornerRadius: 3)) + } + if !isFolded { + Button { + isComposing = true + draftFocused = true + } label: { + Image(systemName: "plus") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.white.opacity(0.62)) + .frame(width: 14, height: 14) + } + .buttonStyle(.plain) + .help("Leave a note on the board") + } + Image(systemName: isFolded ? "chevron.down" : "chevron.up") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(.white.opacity(0.6)) + .frame(width: 14, height: 14) + .background(.white.opacity(0.07), in: RoundedRectangle(cornerRadius: 3)) + .contentShape(Rectangle()) + .onTapGesture(perform: onToggleFold) + } + .contentShape(Rectangle()) + .help(isFolded ? "Show the board" : "Collapse to one line") + } + + /// Folded keeps the newest note, for the reason the summary's fold keeps its beat: a + /// folded section that shows nothing is a section you forget exists. + private var foldedLine: some View { + HStack(spacing: 6) { + Circle() + .fill(accent(for: notes.last)) + .frame(width: 6, height: 6) + Text(notes.last?.body ?? "no notes yet") + .font(.system(size: 11.5)) + .foregroundStyle(.white.opacity(0.75)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + .onTapGesture(perform: onToggleFold) + } + + private var sinceYouLooked: some View { + HStack(spacing: 7) { + Text("SINCE YOU LOOKED") + .font(.system(size: 9.5, weight: .bold)) + .tracking(0.38) + .foregroundStyle(Color(red: 0.549, green: 0.773, blue: 1.0).opacity(0.85)) + Rectangle().fill(Theme.paneFocusTint.opacity(0.35)).frame(height: 1) + } + } + + // MARK: - Records + + /// 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 { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 6) { + Image(systemName: showsRecords ? "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" + ) + .font(.system(size: 10.5)) + .foregroundStyle(.white.opacity(0.5)) + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + .onTapGesture { showsRecords.toggle() } + if showsRecords { + VStack(alignment: .leading, spacing: 7) { + ForEach(records.suffix(8)) { record in + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(ArtifactoryPost.stampFormat.string(from: record.at)) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(.white.opacity(0.32)) + Text(record.body) + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.5)) + .lineLimit(1) + .truncationMode(.tail) + } + } + if records.count > 8 { + Text("\(records.count - 8) earlier") + .font(.system(size: 10.5)) + .foregroundStyle(.white.opacity(0.4)) + } + } + .padding(.leading, 14) + } + } + } + } + + // MARK: - Posts + + private func postRow(_ post: ArtifactoryPost) -> some View { + let read = !unreadIDs.contains(post.id) + return HStack(alignment: .top, spacing: 8) { + RoundedRectangle(cornerRadius: 1) + .fill(accent(for: post).opacity(read ? 0.45 : 1)) + .frame(width: 2) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + if let topic = post.topic { + Text(topic.uppercased()) + .font(.system(size: 9.5, weight: .bold)) + .tracking(0.48) + .foregroundStyle(accent(for: post).opacity(read ? 0.85 : 1)) + .lineLimit(1) + } + Text(ArtifactoryPost.stampFormat.string(from: post.at)) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(.white.opacity(0.4)) + Spacer(minLength: 0) + } + Text(post.body) + .font(.system(size: 12.5)) + .lineSpacing(2) + .foregroundStyle(.white.opacity(read ? 0.55 : 0.9)) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 4) { + if post.authorID == nil { + Image(systemName: "person") + .font(.system(size: 8)) + .foregroundStyle(.white.opacity(0.38)) + } + Text(post.author) + .font(.system(size: 10.5)) + .foregroundStyle(.white.opacity(0.38)) + .lineLimit(1) + } + } + } + } + + /// 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 { + 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 + } + return author.loopType.accent + } + + // MARK: - Composing + + /// A human's voice on the board. Anchored at the foot, where the post will land. + private var composer: some View { + VStack(alignment: .leading, spacing: 7) { + TextField("topic (optional)", text: $draftTopic) + .textFieldStyle(.plain) + .font(.system(size: 10.5)) + .foregroundStyle(.white.opacity(0.75)) + TextField("a note for whoever comes next", text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .font(.system(size: 12.5)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(2...6) + .focused($draftFocused) + HStack(spacing: 6) { + Image(systemName: "person") + .font(.system(size: 8)) + .foregroundStyle(.white.opacity(0.42)) + Text("posting as a human") + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.42)) + Spacer(minLength: 0) + // Shown only as the bound approaches: a counter on an empty field is chrome, + // and the daemon refuses anything over this anyway. + if remainingBytes <= 120 { + Text("\(remainingBytes)") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(remainingBytes < 0 ? .red : .white.opacity(0.5)) + } + Button("Cancel") { cancelCompose() } + .buttonStyle(.plain) + .font(.system(size: 10.5)) + .foregroundStyle(.white.opacity(0.5)) + .keyboardShortcut(.cancelAction) + Button("Post") { submit() } + .buttonStyle(.plain) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle( + canPost + ? AnyShapeStyle(Color(red: 0.549, green: 0.773, blue: 1.0)) + : AnyShapeStyle(.white.opacity(0.3)) + ) + .disabled(!canPost) + .keyboardShortcut(.return, modifiers: .command) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 9) + .background(.white.opacity(0.045), in: RoundedRectangle(cornerRadius: 9)) + .overlay { + RoundedRectangle(cornerRadius: 9) + .stroke(Theme.paneFocusTint.opacity(0.45), lineWidth: 1) + } + } + + private var trimmedDraft: String { + draft.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var remainingBytes: Int { + ArtifactoryPost.maxBodyBytes - trimmedDraft.utf8.count + } + + private var canPost: Bool { !trimmedDraft.isEmpty && remainingBytes >= 0 } + + private func submit() { + guard canPost else { return } + let topic = draftTopic.trimmingCharacters(in: .whitespacesAndNewlines) + onPost(trimmedDraft, topic.isEmpty ? nil : topic) + cancelCompose() + } + + private func cancelCompose() { + isComposing = false + draftFocused = false + draft = "" + draftTopic = "" + } +} diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift index 0d4bf0f6..a5493f3f 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift @@ -39,6 +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() /// 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 @@ -102,6 +103,11 @@ struct LoopWorkspaceFeature { case summaryFoldToggled /// The board section's header row. case boardFoldToggled + case artifactoryFoldToggled + /// 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?) /// 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 @@ -255,6 +261,16 @@ struct LoopWorkspaceFeature { LoopWorkspaceRail.saveBoardFolded(state.isBoardFolded) return .none + case .artifactoryFoldToggled: + state.isArtifactoryFolded.toggle() + LoopWorkspaceRail.saveArtifactoryFolded(state.isArtifactoryFolded) + 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: + return .none + case .boardExpandToggled: state.isBoardExpanded.toggle() return .none diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift index 5d6b8a03..8ed101b8 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceRail.swift @@ -25,10 +25,21 @@ 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 + /// way `AppSidebarView` takes `sharesLoops`: the settings model is `@Observable` and + /// the render path should not be reading a file. + let artifactoryEnabled: Bool + /// Whether the board section is collapsed to its one line, beside the summary's and + /// the diagram's own folds. + let isArtifactoryFolded: Bool let onSummaryFoldToggled: () -> Void let onSummaryAnswerTapped: () -> Void let onBoardFoldToggled: () -> Void let onBoardExpanded: () -> Void + let onArtifactoryFoldToggled: () -> 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 onTargetTapped: (UUID) -> Void /// The handoff's number, and now the floor rather than the fixed size. Below this the @@ -61,6 +72,16 @@ struct LoopWorkspaceRail: View { UserDefaults.standard.double(forKey: widthDefaultsKey) > 0 } + static let artifactoryFoldedDefaultsKey = "loopArtifactorySectionFolded" + + static func loadArtifactoryFolded() -> Bool { + UserDefaults.standard.bool(forKey: artifactoryFoldedDefaultsKey) + } + + static func saveArtifactoryFolded(_ folded: Bool) { + UserDefaults.standard.set(folded, forKey: artifactoryFoldedDefaultsKey) + } + static let boardFoldedDefaultsKey = "loopBoardSectionFolded" static func loadBoardFolded() -> Bool { @@ -119,9 +140,14 @@ struct LoopWorkspaceRail: View { static func hasContent( node: LoopNode, graph: LoopGraph, summarising: Bool = LoopSummaryPresentation.isProducing, - drawing: Bool = SummaryBoardPresentation.isDrawing + drawing: Bool = SummaryBoardPresentation.isDrawing, + artifactoryEnabled: Bool = SettingsModel.shared.settings.artifactoryEnabled ) -> Bool { - graph.edges.contains { $0.from == node.id || $0.to == node.id } + // 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) + || 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 // anything — and that narration is the reason to open the rail at all. With the @@ -169,6 +195,14 @@ struct LoopWorkspaceRail: View { node: node, isFolded: isBoardFolded, onToggleFold: onBoardFoldToggled, onExpand: onBoardExpanded) } + // Above `THIS LOOP` for the same reason the summary is: what other loops have + // said to you outranks where you sit in the graph, and a section you have to + // scroll to is a section that answers nothing at a glance. + if ArtifactoryPresentation.hasContent(graph: graph, enabled: artifactoryEnabled) { + ArtifactorySection( + node: node, graph: graph, isFolded: isArtifactoryFolded, + onToggleFold: onArtifactoryFoldToggled, onPost: onArtifactoryPost) + } section("THIS LOOP") { RailMinimap(node: node, upstream: inbound, downstream: outbound.map(\.target)) } diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift index 3cbe16b0..e97c825e 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift @@ -12,6 +12,10 @@ struct LoopWorkspaceView: View { /// What the loop bar's elapsed label is measured against — the window's one 30s tick, /// the same one the canvases' cards use. See `CanvasClock`. @State private var now = Date() + /// 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 } var body: some View { HStack(spacing: 0) { workspace @@ -23,10 +27,16 @@ struct LoopWorkspaceView: View { isSummaryFolded: store.isSummaryFolded, seenBeatID: store.seenBeatID, isBoardFolded: store.isBoardFolded, + artifactoryEnabled: artifactoryEnabled, + isArtifactoryFolded: store.isArtifactoryFolded, onSummaryFoldToggled: { store.send(.summaryFoldToggled) }, onSummaryAnswerTapped: { store.send(.summaryAnswerTapped) }, onBoardFoldToggled: { store.send(.boardFoldToggled) }, - onBoardExpanded: { store.send(.boardExpandToggled) } + onBoardExpanded: { store.send(.boardExpandToggled) }, + onArtifactoryFoldToggled: { store.send(.artifactoryFoldToggled) }, + onArtifactoryPost: { text, topic in + store.send(.artifactoryPostSubmitted(text: text, topic: topic)) + } ) { targetID in store.send(.railTargetTapped(targetID)) } diff --git a/graphcode/Tests/ArtifactoryBudgetTests.swift b/graphcode/Tests/ArtifactoryBudgetTests.swift new file mode 100644 index 00000000..e2f6caac --- /dev/null +++ b/graphcode/Tests/ArtifactoryBudgetTests.swift @@ -0,0 +1,279 @@ +import ArtifactoryKit +import ComposableArchitecture +import Foundation +import GraphcodeKit +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 { + private func makeStore( + enabled: Bool = true, + delivered: LockIsolated<[(UUID, String)]>? = nil, + memory: LockIsolated<[(UUID, String)]>? = nil + ) async -> GraphStore { + let store = GraphStore( + onEnsureSession: { _, _ in }, + onDeliverMessage: { node, message, _ in + delivered?.withValue { $0.append((node.id, message)) } + return true + }, + onAppendMemory: { nodeID, entry in memory?.withValue { $0.append((nodeID, entry)) } }, + onArtifactoryEnabled: { enabled }) + await store.handle( + .createNode(NodeDraft(title: "Author", loopType: .turnBased, firstInstruction: "Work"))) + await store.handle( + .createNode(NodeDraft(title: "Peer", loopType: .turnBased, firstInstruction: "Work"))) + return store + } + + private func ids(_ store: GraphStore) async -> [UUID] { + await store.graph.nodes.map(\.id) + } + + // MARK: - Separate budgets + + /// The finding that started this round: a graph that merely talks used to evict every + /// note on its board, because notes and mirrored records pruned from one pool. + @Test + func graphChatterCannotEvictANote() async { + let store = await makeStore() + 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) { + await store.handle( + .messageNode(ids[1], text: "ping \(index)", from: ids[0], followUp: true)) + } + + let board = await store.graph.artifactory + #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) + } + + /// And the converse: notes fill their own budget without evicting the records a loop + /// joining mid-flight reads to catch up. + @Test + func notesCannotEvictTheRecords() async { + let store = await makeStore() + 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])) + } + + let board = await store.graph.artifactory + #expect(board.filter { $0.kind == .record }.count == 1) + #expect(board.filter { $0.kind == .note }.count == Artifactory.maxNotes) + // Ids still only grow, so no cursor mistakes an old post for new mail. + #expect(board.last?.id == Artifactory.maxNotes + 11) + } + + @Test + func pruningKeepsTheBoardInOneSequence() { + let base = Date() + var posts: [ArtifactoryPost] = [] + for index in 1...(Artifactory.maxRecords + 4) { + posts.append( + ArtifactoryPost( + id: index, at: base, authorID: nil, author: "a human", topic: nil, + body: "r\(index)", kind: .record)) + posts.append( + ArtifactoryPost( + id: index + 1000, at: base, authorID: nil, author: "a human", topic: nil, + body: "n\(index)", kind: .note)) + } + let pruned = Artifactory.pruned(posts.sorted { $0.id < $1.id }) + #expect(pruned == pruned.sorted { $0.id < $1.id }) + #expect(pruned.filter { $0.kind == .record }.count == Artifactory.maxRecords) + } + + /// A board written before records had a kind decodes as all notes — everything on it + /// was posted deliberately. + @Test + func postsSavedBeforeKindExistedDecodeAsNotes() throws { + 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) + #expect(post.authorID == nil) + } + + // MARK: - Deleting the author + + /// Deleting a loop used to erase what it had told every other loop. A board that + /// un-says things is not a board: the note stays and the handle goes. + @Test + func deletingTheAuthorKeepsTheNoteAndTakesTheHandle() async { + let store = await makeStore() + let ids = await ids(store) + await store.handle( + .artifactoryPost(text: "issue #12 is mine", topic: "claims", from: ids[0])) + + await store.handle(.deleteNode(ids[0])) + + let board = await store.graph.artifactory + #expect(board.count == 1) + #expect(board[0].body == "issue #12 is mine") + #expect(board[0].authorID == nil) + #expect(board[0].author == "Author (deleted)") + } + + /// A loop created after the author is gone still finds the note — the property the + /// whole feature exists for. + @Test + 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(.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) + #expect(unread.map(\.body) == ["approach X fails"]) + } + + /// Deleting a loop still takes its edges and memory — the note surviving is not a + /// licence for the rest of the teardown to stop happening. + @Test + func deleteStillTearsDownEverythingElse() async throws { + let removed = LockIsolated<[UUID]>([]) + let store = GraphStore( + onEnsureSession: { _, _ in }, + onRemoveMemory: { nodeID in removed.withValue { $0.append(nodeID) } }, + onArtifactoryEnabled: { 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(.deleteNode(id)) + + #expect(removed.value == [id]) + #expect(await store.graph.nodes.isEmpty) + #expect(await store.graph.artifactory.count == 1) + } + + // MARK: - Self-triaging sync + + @Test + func aLargeBacklogRendersAsHeadlinesAndSaysSo() { + 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( + 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( + graph, unreadFor: reader.id, autoTriage: true) + + #expect(rendered.contains("headlines only")) + #expect(rendered.contains("artifactory read /tmp/p ")) + } + + @Test + func aSmallBacklogStillPrintsEveryBody() { + 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( + id: 1, at: Date(), authorID: nil, author: "a human", topic: nil, + body: "short enough to read in full")) + + let rendered = GraphcodeCommand.renderArtifactory( + graph, unreadFor: reader.id, autoTriage: true) + + #expect(rendered.contains("short enough to read in full")) + #expect(!rendered.contains("headlines only")) + } + + /// Bytes, not just count: a handful of kilobyte notes is the same problem as forty + /// short ones. + @Test + func aFewVeryLongNotesTriageOnBytes() { + let posts = (1...5).map { index in + ArtifactoryPost( + 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)))) + } + + @Test + func syncParsesFullAndDefaultsToAutoTriage() throws { + let full = try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/p", "--full"]) + #expect( + full + == .artifactorySync( + projectPath: "/tmp/p", headlines: false, mark: false, json: false, full: true)) + let plain = try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/p"]) + #expect( + plain + == .artifactorySync( + 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 + /// 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) + #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") + #expect(ask?.contains("distill it into a project skill") == true) + #expect(ask?.contains("graphcode artifactory post") == true) + #expect(ask?.components(separatedBy: "[graphcode] ").count == 2) + } + + @Test + func noBoardAndNoSkillMeansNoInterruption() { + #expect(MessageBus.resolutionAsk(distillSkill: false, artifactoryProjectPath: nil) == nil) + } + + /// With the board off, a resolving loop is never pointed at a verb the daemon would + /// refuse. + @Test + func theAskIsGatedWithTheRestOfTheBoard() async throws { + let delivered = LockIsolated<[(UUID, String)]>([]) + let store = GraphStore( + onEnsureSession: { _, _ in }, + onDeliverMessage: { node, message, _ in + delivered.withValue { $0.append((node.id, message)) } + return true + }, + onArtifactoryEnabled: { false }) + await store.handle( + .createNode( + NodeDraft( + title: "Worker", loopType: .goalBased, goal: GoalSpec(summary: "CI passes")))) + let id = try #require(await store.graph.nodes.first?.id) + + await store.handle(.nodeCheckApproved(id)) + + #expect(!delivered.value.contains { $0.1.contains("artifactory post") }) + } +} diff --git a/graphcode/Tests/ArtifactoryCommandTests.swift b/graphcode/Tests/ArtifactoryCommandTests.swift index 3a275828..611113e3 100644 --- a/graphcode/Tests/ArtifactoryCommandTests.swift +++ b/graphcode/Tests/ArtifactoryCommandTests.swift @@ -43,7 +43,8 @@ struct ArtifactoryCommandTests { func syncAndListTakeOnlyAProjectPath() throws { #expect( try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/x"]) - == .artifactorySync(projectPath: "/tmp/x", headlines: false, mark: false, json: false)) + == .artifactorySync( + 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)) @@ -206,13 +207,17 @@ private func boardWithPosts() -> (LoopGraph, LoopNode) { func syncParsesItsReadModes() throws { let plain = try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/x"]) #expect( - plain == .artifactorySync(projectPath: "/tmp/x", headlines: false, mark: false, json: false)) + plain + == .artifactorySync( + projectPath: "/tmp/x", headlines: false, mark: false, json: false, full: false)) let all = try GraphcodeCommand.parse([ "artifactory", "sync", "/tmp/x", "--headlines", "--mark", "--json", ]) #expect( - all == .artifactorySync(projectPath: "/tmp/x", headlines: true, mark: true, json: true)) + all + == .artifactorySync( + projectPath: "/tmp/x", headlines: true, mark: true, json: true, full: false)) #expect { try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/x", "--search", "x"]) diff --git a/graphcode/Tests/ArtifactoryTests.swift b/graphcode/Tests/ArtifactoryTests.swift index 9b5546c2..dfe79514 100644 --- a/graphcode/Tests/ArtifactoryTests.swift +++ b/graphcode/Tests/ArtifactoryTests.swift @@ -91,14 +91,14 @@ struct ArtifactoryTests { let store = await makeStore() let ids = nodeIDs(await store.graph) - for index in 0..<(Artifactory.maxPosts + 5) { + for index in 0..<(Artifactory.maxNotes + 5) { await store.handle(.artifactoryPost(text: "post \(index)", topic: nil, from: ids[0])) } let graph = await store.graph - #expect(graph.artifactory.count == Artifactory.maxPosts) + #expect(graph.artifactory.count == Artifactory.maxNotes) #expect(graph.artifactory.first?.body == "post 5") - #expect(graph.artifactory.last?.id == Artifactory.maxPosts + 5) + #expect(graph.artifactory.last?.id == Artifactory.maxNotes + 5) } @Test @@ -301,8 +301,11 @@ extension ArtifactoryTests { // MARK: - Shared-communication mirroring // MARK: - Deletion + /// Deleting a loop takes the handle to its posts, never the posts. A note is + /// addressed to whoever comes next, peers may already have acted on it, and a board + /// that un-says things is not a board. @Test - func deletingALoopRemovesItsArtifactoryPosts() async { + func deletingALoopKeepsItsPostsAndTakesTheirHandle() async { let store = await makeStore() let ids = nodeIDs(await store.graph) await store.handle(.artifactoryPost(text: "mine", topic: nil, from: ids[0])) @@ -312,7 +315,13 @@ extension ArtifactoryTests { // MARK: - Shared-communication mirroring let graph = await store.graph #expect(graph.nodes.count == 1) - #expect(graph.artifactory.map(\.body) == ["theirs"]) + #expect(graph.artifactory.map(\.body) == ["mine", "theirs"]) + let orphaned = graph.artifactory[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") } @Test @@ -331,7 +340,7 @@ extension ArtifactoryTests { // MARK: - Shared-communication mirroring } @Test - func deletingALoopRemovesItsSpawnedDescendantsPostsToo() async throws { + func deletingALoopOrphansItsSpawnedDescendantsPostsToo() async throws { let store = await makeStore() let ids = nodeIDs(await store.graph) await store.handle( @@ -346,8 +355,13 @@ extension ArtifactoryTests { // MARK: - Shared-communication mirroring await store.handle(.deleteNode(ids[0])) let graph = await store.graph - // Custody: the child went with the parent, and its board record goes too. - #expect(graph.artifactory.isEmpty) + // Custody: the child went with the parent, and both their notes stay on the board + // 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)"]) } } From ee94bbd389d704edb2ec24c00636d6b051f493fb Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 05:08:43 -0700 Subject: [PATCH 10/10] Package.swift: ArtifactoryKit, so the Linux build has the module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The required `Linux build` check has failed since ArtifactoryKit was split out: the module went into `Project.swift` (Tuist, which builds the Mac app) and not into `Package.swift` (SwiftPM, which is what CI runs), so every file importing it failed with "no such module 'ArtifactoryKit'" on Linux while compiling perfectly on a Mac. Added as a target and as a product — `GraphcodeKit` exposes `ArtifactoryPost` through `LoopGraph`, so anything importing the kit needs this module in scope. Verified with exactly what the workflow runs: `swift build` completes, and `.build/debug/graphcode` exits 0. Signed-off-by: scgopi Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019A6NULwEiBXEdRXwEKKcRH --- Package.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 1c24e4db..e17c7972 100644 --- a/Package.swift +++ b/Package.swift @@ -10,6 +10,7 @@ let package = Package( name: "graphcode", platforms: [.macOS(.v15)], products: [ + .library(name: "ArtifactoryKit", targets: ["ArtifactoryKit"]), .library(name: "GraphcodeKit", targets: ["GraphcodeKit"]), .executable(name: "graphcode", targets: ["graphcode-cli"]), .executable(name: "graphcoded", targets: ["graphcoded"]), @@ -21,10 +22,21 @@ let package = Package( ) ], targets: [ + // 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`, + // so it is a product too — anything importing the kit needs this module in scope. + .target( + name: "ArtifactoryKit", + path: "ArtifactoryKit/Sources", + swiftSettings: [.swiftLanguageMode(.v5)] + ), .target( name: "GraphcodeKit", dependencies: [ - .product(name: "IdentifiedCollections", package: "swift-identified-collections") + "ArtifactoryKit", + .product(name: "IdentifiedCollections", package: "swift-identified-collections"), ], path: "GraphcodeKit/Sources", // Language mode 5 to match how Tuist/Xcode builds these same sources today;