diff --git a/ArtifactoryKit/Sources/Artifactory.swift b/ArtifactoryKit/Sources/Artifactory.swift new file mode 100644 index 00000000..1a8eccac --- /dev/null +++ b/ArtifactoryKit/Sources/Artifactory.swift @@ -0,0 +1,187 @@ +import Foundation + +/// 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 +/// comes next* — a decision made, a dead end hit, a claim staked — and any other loop, +/// present or created after the author is gone, discovers it with one command. Posts +/// survive their authors: they live on the graph itself, outlasting resolution, the +/// 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, + /// 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`. + public let author: String + /// An optional label for threads that keep themselves together — `auth`, `build`, + /// `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 + /// 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 + /// `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, + kind: Kind = .note + ) { + self.id = id + self.at = at + self.authorID = authorID + 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 + /// 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 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 ArtifactoryWatch: 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 Artifactory { + /// How many *notes* a board keeps. The oldest fall off first: an Artifactory is a + /// mailbox for the work that is happening, not an archive — a loop's durable + /// findings belong in its memory log, and the board's job is carrying them to + /// loops that cannot read that log. + public static let maxNotes = 200 + + /// How many mirrored records a board keeps, pruned entirely separately from the + /// notes. Smaller because a record is a receipt for something already delivered: + /// enough that a loop joining mid-flight can see what was recently said, not so + /// many that the graph's chatter becomes the board. + public static let maxRecords = 50 + + /// The id the next post gets. Maximum-plus-one, never count-plus-one: pruning + /// removes the oldest posts, and reusing their ids would make unread cursors + /// mistake old mail for new. + public static func nextID(after posts: [ArtifactoryPost]) -> Int { + (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? + ) -> [ArtifactoryPost] { + guard let lastRead else { return posts } + return posts.filter { $0.id > lastRead } + } + + /// A board pruned to both budgets, oldest of each kind gone first and the survivors + /// back in one sequence. Applied by the store on every write so no caller can forget. + public static func pruned(_ posts: [ArtifactoryPost]) -> [ArtifactoryPost] { + let notes = posts.filter { $0.kind == .note } + let records = posts.filter { $0.kind == .record } + guard notes.count > maxNotes || records.count > maxRecords else { return posts } + 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 9b611189..8d7f9ab5 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -1,3 +1,4 @@ +import ArtifactoryKit import Foundation /// Argument parsing and output formatting for the `graphcode` CLI @@ -43,6 +44,28 @@ public enum GraphcodeCommand: Equatable, Sendable { case exportNode(projectPath: String, nodeID: UUID, output: String, includeChildren: Bool = false) case exportGraph(projectPath: String, output: String) case importNodes(projectPath: String, fromZip: String, asChildOf: UUID? = nil) + /// The Artifactory verbs (docs/03-architecture.md#cli-graphcode): the shared, + /// unaddressed board any loop can write to and read. Attribution is not parsed — + /// like `sendMessage`, the sender comes from `ZMX_SESSION` at execution. + case artifactoryPost(projectPath: String, topic: String?, text: String) + /// Read unread, then mark the board read — the cursor belongs to the calling loop, + /// so this verb only means anything run from inside a session. `--headlines` prints + /// one triage line per unread post instead of full bodies (pair it with `read`); + /// `--mark` advances the cursor without printing the backlog ("start me from now"); + /// `--json` emits the unread posts machine-readably; `--full` insists on every body + /// where the verb would otherwise triage a large backlog down to headlines itself. + case artifactorySync( + 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) + /// The whole board, read-only: no command reaches the daemon, no cursor moves. + /// `--search` filters by substring across author/topic/body; `--json` emits the + /// board machine-readably. + case artifactoryList(projectPath: String, search: String?, json: Bool) + /// 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?) public enum ParseError: Error, Equatable { case unknownCommand(String) @@ -71,6 +94,25 @@ public enum GraphcodeCommand: Equatable, Sendable { graphcode node pilot dry-run a composite graphcode node arm arm it (needs a pilot first) graphcode edge create [--kind ] [--condition ] + graphcode artifactory post [--topic ] + leave a note on the shared board for whoever comes next + graphcode artifactory sync [--headlines] [--full] [--mark] [--json] + read your unread posts and mark the board read. A large + 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 > --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] + 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 graphcode usage graphcode reap [--dry-run] recover suspected orphaned zmx sessions when PTYs cannot be allocated or deleted loops leave sessions behind @@ -177,6 +219,19 @@ public enum GraphcodeCommand: Equatable, Sendable { --into spawn into a different project (--kind spawn only); this is how the global graph dispatches work into a project + ARTIFACTORY + The shared, unaddressed board: `node send` reaches one peer you already know; + an Artifactory post is a note for whoever comes next, discoverable by loops that + did not exist when it was written. Run from inside a loop, posts are attributed + to that loop (`ZMX_SESSION`, the same mechanism as `node send`); from a human's + shell they read as from "a human". `sync` and `watch` need that loop identity — + the read cursor and the subscription belong to a loop — so a human reads the + board with `list`. A post is a note to a peer, not a transcript: 1 KB bound, + and `--topic ` groups a thread (a watcher of a topic only hears matching + posts; watched posts are delivered like a --follow-up message). Note that the + mirrored `direct` and `handoff` records never ring watchers — they are the + board's record of traffic that already had its own delivery, so a watch on + those topics alone stays silent. EXIT CODES 0 done 1 bad usage, or graphcoded refused the command @@ -192,6 +247,10 @@ public enum GraphcodeCommand: Equatable, Sendable { graphcode status graphcode node send --follow-up stage work without interrupting an active turn + graphcode artifactory sync + check what other loops left for you before starting a pass + 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 pilot before arming a proactive routine @@ -324,6 +383,9 @@ public enum GraphcodeCommand: Equatable, Sendable { throw ParseError.unknownCommand("node \(verb)") } + case "artifactory": + return try parseArtifactory(&arguments) + case "edge": let verb = try take(&arguments, name: "edge subcommand") guard verb == "create" else { throw ParseError.unknownCommand("edge \(verb)") } @@ -681,10 +743,18 @@ 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") + // 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 { @@ -713,6 +783,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") } @@ -748,6 +823,154 @@ extension GraphcodeCommand { return projects.map { "\($0.name) \($0.path)" }.joined(separator: "\n") } + /// The board for a terminal. `artifactory list` prints the whole thing (`reader` + /// nil); `artifactory sync` passes the reading loop's id and prints only what its + /// cursor has not covered — the subtraction is `Artifactory.unread`, the arithmetic + /// the daemon's cursor contract rests on, so the CLI's "unread" and the store's can + /// never disagree. `headlines` truncates each body to a triage line's worth (the + /// deep read is `artifactory read `); `search` keeps only posts whose author, + /// 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, headlines: Bool = false, + search: String? = nil, autoTriage: Bool = false + ) -> String { + 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" + } + // `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 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 || triaged ? " \(renderHeadline(post))" : " \(render(post))") + } + return lines.joined(separator: "\n") + } + + /// The board as one machine-readable object — the same posts `renderArtifactory` + /// 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, search: String? = nil + ) -> String { + struct Board: Encodable { + var posts: [ArtifactoryPost] + var lastRead: Int? + } + 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 lastRead = readerID.flatMap { graph.nodes[id: $0]?.lastArtifactoryRead } + let board = Board(posts: posts, lastRead: lastRead) + 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) + } + + /// `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" + // "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 + 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 { + let topic = post.topic.map { " (\($0))" } ?? "" + // `Date.formatted` has no precedent in GraphcodeKit and corelibs-foundation's + // FormatStyle support has been uneven across the toolchains the Linux CI runs; + // a fixed DateFormatter is the boring, portable answer. + let stamp = ArtifactoryPost.stampFormat.string(from: post.at) + 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 { + // 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)) + "…" + } + + /// `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.artifactory.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)" @@ -846,4 +1069,62 @@ extension GraphcodeCommand { } return .importNodes(projectPath: projectPath, fromZip: zipPath, asChildOf: asChildOf) } + + /// 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 artifactory post --topic claims issue #12 is mine` needs no + /// quoting gymnastics — with `--topic ` riding along in either position. + fileprivate static func parseArtifactory( + _ arguments: inout [String] + ) throws -> GraphcodeCommand { + 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 { + 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 .artifactoryPost(projectPath: path, topic: flags["topic"], text: text) + + case "sync": + 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, full: flags["full"] != nil) + + case "read": + try validateFlags(arguments, allowed: []) + let raw = try take(&arguments, name: "post-id") + // 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) + + case "list": + 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"]) + let flags = parseFlags(arguments) + return .artifactoryWatch(projectPath: path, on: flags["off"] == nil, topic: flags["topic"]) + + default: + throw ParseError.unknownCommand("artifactory \(verb)") + } + } } diff --git a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift index 05a4e061..90b2d2da 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 **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 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.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 `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 artifactoryEnabled: 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, + artifactoryEnabled: 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.artifactoryEnabled = artifactoryEnabled 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. + 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 8b2a1bc9..47761a55 100644 --- a/GraphcodeKit/Sources/Domain/LoopGraph.swift +++ b/GraphcodeKit/Sources/Domain/LoopGraph.swift @@ -1,3 +1,4 @@ +import ArtifactoryKit import Foundation import IdentifiedCollections @@ -19,6 +20,16 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { public var scope: LoopGraphScope public var nodes: IdentifiedArrayOf public var edges: IdentifiedArrayOf + /// The project's Artifactory — every post any loop has dropped onto the shared board, + /// 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 + /// the global graph at `graphcode://global` becoming a cross-project board without + /// a line of extra code. Empty for anyone who never touches the board; graphs saved + /// before the field existed decode with it empty. + public var artifactory: [ArtifactoryPost] = [] public var project: ProjectRef { get { scope.projectRef } @@ -234,7 +245,7 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { // MARK: - Coding private enum CodingKeys: String, CodingKey { - case id, nodes, edges + 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 @@ -249,6 +260,7 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { scope = LoopGraphScope(projectPath: ref.path, name: ref.name) nodes = try container.decodeIfPresent(IdentifiedArrayOf.self, forKey: .nodes) ?? [] edges = try container.decodeIfPresent(IdentifiedArrayOf.self, forKey: .edges) ?? [] + artifactory = try container.decodeIfPresent([ArtifactoryPost].self, forKey: .artifactory) ?? [] } public func encode(to encoder: Encoder) throws { @@ -257,5 +269,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 !artifactory.isEmpty { try container.encode(artifactory, forKey: .artifactory) } } } diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index 687599b2..03593aca 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -1,3 +1,4 @@ +import ArtifactoryKit import Foundation /// One node in a graph of loops: a unit of agentic work with a well-defined hand-off @@ -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 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 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 artifactoryWatch: ArtifactoryWatch? /// Why the loop is `.stalled`, when the graph knows. A budget exhaustion and a stall /// bound both land in the same terminal state, and both wrote their reason only to /// the loop's memory log — every surface then showed a bare STALLED and a human had @@ -166,6 +179,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { presence: PresenceReading? = nil, metricHistory: [MetricSample] = [], createdBy: UUID? = nil, + lastArtifactoryRead: Int? = nil, + artifactoryWatch: ArtifactoryWatch? = nil, stallReason: String? = nil, state: LoopState = .idle, createdAt: Date = Date() @@ -191,6 +206,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { self.presence = presence self.metricHistory = metricHistory self.createdBy = createdBy + self.lastArtifactoryRead = lastArtifactoryRead + self.artifactoryWatch = artifactoryWatch self.stallReason = stallReason self.state = state self.createdAt = createdAt @@ -429,6 +446,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { private enum CodingKeys: String, CodingKey { case id, title, loopType, checkDescription, triggerPrompt, goal, backend, modelTier case worktreeBinding, subGraph, pilotState, usage, metricHistory, createdBy + case lastArtifactoryRead, artifactoryWatch case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly case summary, board, heartbeatIntervalSeconds, stallReason } @@ -470,6 +488,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 Artifactory existed — every loop simply has + // not read anything yet, which is what `nil` says. + lastArtifactoryRead = try container.decodeIfPresent(Int.self, forKey: .lastArtifactoryRead) + artifactoryWatch = try container.decodeIfPresent( + ArtifactoryWatch.self, forKey: .artifactoryWatch) stallReason = try container.decodeIfPresent(String.self, forKey: .stallReason) state = try container.decodeIfPresent(LoopState.self, forKey: .state) ?? .idle createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() diff --git a/GraphcodeKit/Sources/Domain/SessionBriefing.swift b/GraphcodeKit/Sources/Domain/SessionBriefing.swift index bba4d938..5772208e 100644 --- a/GraphcodeKit/Sources/Domain/SessionBriefing.swift +++ b/GraphcodeKit/Sources/Domain/SessionBriefing.swift @@ -77,6 +77,48 @@ public enum SessionBriefing { Do not reach for this for one-off work: "check the build" is a goal, "check the build every hour" is time-based. """ + // The Artifactory's section exists only while the beta ramp has the feature on: a + // briefing that taught verbs the daemon would refuse would send every loop + // through a refusal once per idea. It interpolates inline after the "one-off." + // sentence (the value leading with blank lines) so that off — an empty value — + // leaves the briefing byte-for-byte what it was before this section existed. + let artifactorySection = + settings.artifactoryEnabled + ? """ + + + ## The Artifactory — notes for whoever comes next + + `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 artifactory sync \(projectPath) # read what you have not seen, mark it read + graphcode artifactory read \(projectPath) # one post in full + graphcode artifactory post \(projectPath) [--topic ] # leave something behind + graphcode artifactory list \(projectPath) # read-only peek, cursor untouched + graphcode artifactory watch \(projectPath) [--topic ] # ring me when new mail lands + ``` + + Post decisions made, dead ends hit, claims staked ("I'm taking issue #12") — + a note for a peer, not a transcript. Sync before you rely on nobody having + got there first, and watch a topic when you want the board to come to you. + A big backlog prints as one line per post and says so; `read ` then + spends context only on the ones that turned out to matter. + + The board also keeps the record for you: every direct message, message-edge + delivery, and handoff (topics `direct` and `handoff`) is mirrored onto it + automatically, so a loop that joins mid-flight can read what was already said. + Those mirrored records are the record, not the delivery — they never ring a + watcher, so watching only those topics stays silent, and they prune on their + own budget so graph chatter can never crowd out a note. Your posts outlive + you: they stay after you resolve, and after your loop is deleted — only the + byline goes. + """ + : "" return """ # You are a loop in a graphcode graph @@ -146,7 +188,7 @@ public enum SessionBriefing { the exact command for reporting results back to it. For recurring communication, an edge is still the right tool: a `message` edge fires automatically when you finish, a `handoff` sequences the other loop after you. This command is the - one-off. + one-off.\(artifactorySection) ## Remembering across passes diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index c13cb5a4..ca26b4cd 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -1,3 +1,4 @@ +import ArtifactoryKit import Foundation /// Owns the daemon's one `LoopGraph`, applies commands, automatically fires `.handoff` @@ -84,6 +85,11 @@ public actor GraphStore { /// switching it off empties the boards on the next poll without restarting anything, the /// same contract `onHeartbeatEnabled` has. private let onBoardsEnabled: (@Sendable () -> Bool)? + /// Whether the Artifactory is on — read fresh at every gate — so flipping the Settings + /// toggle (or the beta ramp resolving) applies to the next post without restarting + /// anything. `nil` (tests that don't care, and any client that never wires it) means + /// off, which is the ramp's default. + private let onArtifactoryEnabled: (@Sendable () -> Bool)? /// 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 @@ -209,6 +215,7 @@ public actor GraphStore { @Sendable (LoopNode, LoopSummary, String?, String?) async -> SummaryBoard? )? = nil, onBoardsEnabled: (@Sendable () -> Bool)? = nil, + onArtifactoryEnabled: (@Sendable () -> Bool)? = nil, goalCache: GoalEvaluationCache? = nil, recurrence: RecurrenceSink? = nil, subGraphDepth: Int = 0 @@ -235,6 +242,7 @@ public actor GraphStore { self.onHeartbeatEnabled = onHeartbeatEnabled self.onComposeBoard = onComposeBoard self.onBoardsEnabled = onBoardsEnabled + self.onArtifactoryEnabled = onArtifactoryEnabled self.goalCache = goalCache ?? GoalEvaluationCache() self.recurrence = recurrence } @@ -402,6 +410,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 .artifactorySync(let from): + artifactorySync(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) @@ -559,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) @@ -1295,6 +1318,185 @@ public actor GraphStore { recordMemory(nodeID, "playbook rolled back\(sender.map { " by \($0)" } ?? "")") } + // MARK: - Artifactory + + /// 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 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 `artifactory sync`. + private func artifactoryPost(text: String, topic: String?, from senderID: UUID?) async { + guard artifactoryIsOn() else { + announceError( + "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("artifactory post refused: empty note") + return + } + guard trimmed.utf8.count <= ArtifactoryPost.maxBodyBytes else { + announceError( + "artifactory post refused: \(trimmed.utf8.count) bytes is over the " + + "\(ArtifactoryPost.maxBodyBytes)-byte bound — a post is a note to a peer, not " + + "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("artifactory post refused: an empty topic is no topic — omit it") + return + } + guard trimmedTopic?.utf8.count ?? 0 <= ArtifactoryPost.maxTopicBytes else { + announceError( + "artifactory post refused: topic over \(ArtifactoryPost.maxTopicBytes) bytes") + return + } + // 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) + 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, "artifactory: posted #\(post.id)\(topicSuffix(post)) — \(post.body)") + } + await wakeArtifactoryWatchers(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 wakeArtifactoryWatchers(about post: ArtifactoryPost) async { + for node in graph.nodes where node.id != post.authorID { + 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 = + "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: 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. 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 + ) { + 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 { + // 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( + id: Artifactory.nextID(after: graph.artifactory), at: Date(), authorID: senderID, + author: sender, topic: topic, body: body, kind: .record) + graph.artifactory = Artifactory.pruned(graph.artifactory + [post]) + } + + /// Advances the reading loop's cursor to the newest post — the write half of + /// `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 artifactorySync(from readerID: UUID?) { + guard artifactoryIsOn() else { + announceError( + "the Artifactory is off — enable Artifactory in Settings " + + "(artifactoryEnabled in ~/.graphcode/settings.json)") + return + } + guard let readerID, graph.nodes[id: readerID] != nil else { + announceError( + "artifactory sync needs a loop identity — run it from a loop's session " + + "($ZMX_SESSION); a human reading the board needs no cursor") + return + } + // 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.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]?.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 artifactoryWatch(on: Bool, topic: String?, from watcherID: UUID?) { + guard artifactoryIsOn() else { + announceError( + "the Artifactory is off — enable Artifactory in Settings " + + "(artifactoryEnabled in ~/.graphcode/settings.json)") + return + } + guard let watcherID, graph.nodes[id: watcherID] != nil else { + announceError( + "artifactory watch needs a loop identity — run it from a loop's session " + + "($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("artifactory watch refused: an empty topic is no topic — omit it") + return + } + graph.nodes[id: watcherID]?.artifactoryWatch = ArtifactoryWatch(topic: trimmed) + recordMemory( + watcherID, "artifactory: now watching \(trimmed.map { "'\($0)'" } ?? "all posts")") + } else { + // Idempotent, not an error: "stop watching" when nothing is watched is the + // state the caller asked for, and an off state arriving twice is harmless in a + // way a refusal isn't — the second call would be an agent retrying in a loop. + if graph.nodes[id: watcherID]?.artifactoryWatch != nil { + recordMemory(watcherID, "artifactory: stopped watching") + } + graph.nodes[id: watcherID]?.artifactoryWatch = nil + } + } + // MARK: - Import /// Splices an export bundle's loops into this graph — the daemon half of @@ -1322,6 +1524,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 { @@ -1385,6 +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) + // 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 @@ -1531,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) } @@ -1790,6 +2018,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 } } @@ -1876,6 +2115,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)" } ?? "" @@ -1884,8 +2124,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: " ") @@ -1943,7 +2191,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") @@ -1954,6 +2203,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 } @@ -2190,6 +2448,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/GraphcodeKit/Sources/IPC/DaemonProtocol.swift b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift index 13278954..1203cc19 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 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 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 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 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 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 4b6f0f85..78c13174 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. + 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/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/GraphcodeKit/Sources/Sessions/NodeMemory.swift b/GraphcodeKit/Sources/Sessions/NodeMemory.swift index 366e2e71..dfcc40e8 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, artifactoryEnabled: 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 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 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("") + } 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 c6a5a3fa..53ebdb44 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -691,7 +691,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, + artifactoryEnabled: settings.artifactoryEnabled) + : nil let wakePath: String? if let projectPath, remote != nil { wakePath = 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; diff --git a/Project.swift b/Project.swift index e557716d..09390fae 100644 --- a/Project.swift +++ b/Project.swift @@ -20,6 +20,21 @@ let project = Project( name: "graphcode", organizationName: "Graphcode", targets: [ + // `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: "ArtifactoryKit", + destinations: .macOS, + product: .staticFramework, + bundleId: "\(bundleIdPrefix).artifactory", + deploymentTargets: .macOS("15.0"), + buildableFolders: [ + "ArtifactoryKit/Sources" + ] + ), .target( name: "GraphcodeKit", destinations: .macOS, @@ -30,6 +45,7 @@ let project = Project( "GraphcodeKit/Sources" ], dependencies: [ + .target(name: "ArtifactoryKit"), .external(name: "IdentifiedCollections") ] ), @@ -54,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", - "CFBundleVersion": "221", + "CFBundleShortVersionString": "0.1.58-beta1", + "CFBundleVersion": "222", ]), resources: [ "graphcode/Resources/**" diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index 363ced76..8516b3f9 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): @@ -344,6 +350,149 @@ do { projectPath: projectPath, [.graphCommand(projectPath: projectPath, command: .armComposite(nodeID))]) + 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 + // 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: .artifactoryPost(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 .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 + // 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( + "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: .artifactorySync(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. 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 { + 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. + if let latest = graph.artifactory.last?.id, latest > 0 { + print("marked read up to #\(latest)") + } else { + print("marked read — the board is empty") + } + } else { + // `autoTriage` unless the caller said which way they want it: a loop cannot + // know how much mail it has before reading it, and the first sync of a loop + // born after a busy week is the whole board. + print( + GraphcodeCommand.renderArtifactory( + graph, unreadFor: reader, headlines: headlines, + autoTriage: !headlines && !full)) + } + } + + 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. `--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 { + if json { + print(GraphcodeCommand.renderArtifactoryJSON(graph, search: search)) + } else { + print(GraphcodeCommand.renderArtifactory(graph, search: search)) + } + } + + 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( + "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)) + _ = try client.waitForEvent { if case .graphChanged = $0 { return true } else { return false } } + try client.send( + .graphCommand( + projectPath: projectPath, + command: .artifactoryWatch(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 @@ -454,7 +603,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/Sources/Clients/FeatureRamps.swift b/graphcode/Sources/Clients/FeatureRamps.swift index db162099..b78439cc 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 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 @@ -31,6 +32,7 @@ enum FeatureRamps { var defaultPercents: [String: Int] { switch self { case .codespaces: return ["beta": 100, "stable": 100] + case .artifactory: return ["beta": 100, "stable": 0] } } } 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/Sources/Features/Settings/SettingsModel.swift b/graphcode/Sources/Features/Settings/SettingsModel.swift index 8d89f769..ab177dbc 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 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 artifactoryChoiceDefaultsKey = "artifactoryChoice" + var settings: GraphcodeSettings { didSet { guard settings != oldValue else { return } @@ -33,8 +39,41 @@ final class SettingsModel { } } + /// 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 showsArtifactory: Bool + + /// 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 artifactoryEnabled: Bool { + didSet { + UserDefaults.standard.set(artifactoryEnabled, forKey: Self.artifactoryChoiceDefaultsKey) + settings.artifactoryEnabled = artifactoryEnabled + } + } + private init() { - settings = GraphcodeSettingsStore.load() + let loaded = GraphcodeSettingsStore.load() + let artifactory = Self.resolvesArtifactory( + loaded: loaded.artifactoryEnabled, + explicitChoice: + UserDefaults.standard.object(forKey: Self.artifactoryChoiceDefaultsKey) as? Bool, + rampedOn: FeatureRamps.isEnabled(.artifactory)) + var booted = loaded + 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 artifactory.fileNeedsWrite { + GraphcodeSettingsStore.save(booted) + } + artifactoryEnabled = artifactory.enabled + showsArtifactory = artifactory.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 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 + /// `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 resolvesArtifactory( + loaded: Bool, explicitChoice: Bool?, rampedOn: Bool + ) -> ArtifactoryResolution { + let enabled = explicitChoice ?? rampedOn + return ArtifactoryResolution( + enabled: enabled, fileNeedsWrite: enabled != loaded, + showsSwitch: rampedOn || explicitChoice != nil) + } + + 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 d0e98b20..68c19caa 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 `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.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 artifactory 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/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 new file mode 100644 index 00000000..611113e3 --- /dev/null +++ b/graphcode/Tests/ArtifactoryCommandTests.swift @@ -0,0 +1,413 @@ +import ArtifactoryKit +import Foundation +import GraphcodeKit +import Testing + +/// 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 +/// `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 ArtifactoryCommandTests { + // 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(["artifactory", "post", "/tmp/x", "staking", "issue", "#12"]) + == .artifactoryPost(projectPath: "/tmp/x", topic: nil, text: "staking issue #12")) + #expect( + try GraphcodeCommand.parse( + ["artifactory", "post", "/tmp/x", "--topic", "Claims", "staking", "issue", "#12"]) + == .artifactoryPost(projectPath: "/tmp/x", topic: "Claims", text: "staking issue #12")) + #expect( + try GraphcodeCommand.parse( + ["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(["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(["artifactory", "post", "/tmp/x", "--topic", "build"]) + } + } + + @Test + func syncAndListTakeOnlyAProjectPath() throws { + #expect( + try GraphcodeCommand.parse(["artifactory", "sync", "/tmp/x"]) + == .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)) + } + + @Test + func watchDefaultsToEveryPostAndOptsOutWithOff() throws { + #expect( + try GraphcodeCommand.parse(["artifactory", "watch", "/tmp/x"]) + == .artifactoryWatch(projectPath: "/tmp/x", on: true, topic: nil)) + #expect( + try GraphcodeCommand.parse(["artifactory", "watch", "/tmp/x", "--topic", "build"]) + == .artifactoryWatch(projectPath: "/tmp/x", on: true, topic: "build")) + #expect( + try GraphcodeCommand.parse(["artifactory", "watch", "/tmp/x", "--off"]) + == .artifactoryWatch(projectPath: "/tmp/x", on: false, topic: nil)) + #expect( + try GraphcodeCommand.parse(["artifactory", "watch", "/tmp/x", "--topic", "build", "--off"]) + == .artifactoryWatch(projectPath: "/tmp/x", on: false, topic: "build")) + } + + @Test + func aMissingProjectPathIsNamedInTheError() { + #expect(throws: GraphcodeCommand.ParseError.missingArgument("project-path")) { + try GraphcodeCommand.parse(["artifactory", "post"]) + } + #expect(throws: GraphcodeCommand.ParseError.missingArgument("project-path")) { + try GraphcodeCommand.parse(["artifactory", "watch", "--off"]) + } + } + + @Test + 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(["artifactory", "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(["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 + + @Test + func theBoardRendersOneLinePerPost() { + 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: "kickoff"), + ArtifactoryPost( + id: 4, at: Date(timeIntervalSince1970: 100), authorID: UUID(), author: "Author", + topic: "claims", body: "issue #12 is mine"), + ] + + let rendered = GraphcodeCommand.renderArtifactory(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.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: nil, author: "a human", + topic: nil, body: "still unread"), + ] + + 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.renderArtifactory(graph, unreadFor: UUID()).contains("#1")) + } + + @Test + func emptyBoardAndNothingUnreadSaySo() { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + #expect(GraphcodeCommand.renderArtifactory(graph).contains("the board is empty")) + + var reader = LoopNode(title: "Reader", loopType: .turnBased) + reader.lastArtifactoryRead = 3 + graph.nodes.append(reader) + graph.artifactory = [ + ArtifactoryPost( + id: 3, at: Date(timeIntervalSince1970: 0), authorID: nil, author: "a human", + topic: nil, body: "caught up") + ] + + #expect(GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id) == "no unread posts") + } + + @Test + func postedNamesTheNewSequence() { + var graph = LoopGraph(project: ProjectRef(path: "/tmp/x", name: "x")) + #expect(GraphcodeCommand.renderPosted(graph) == "posted") + + graph.artifactory = [ + ArtifactoryPost( + 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 helpTextTeachesTheArtifactoryVerbs() { + for verb in ["artifactory post", "artifactory sync", "artifactory list", "artifactory watch"] { + #expect(GraphcodeCommand.helpText.contains(verb)) + } + } +} + +// 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, 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, full: false)) + + #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 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 decoder.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")) +} + +// 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")) +} diff --git a/graphcode/Tests/ArtifactoryTests.swift b/graphcode/Tests/ArtifactoryTests.swift new file mode 100644 index 00000000..dfe79514 --- /dev/null +++ b/graphcode/Tests/ArtifactoryTests.swift @@ -0,0 +1,517 @@ +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, + errors: LockIsolated<[String]>? = nil, + presence: Presence? = nil + ) async -> GraphStore { + let store = GraphStore( + onEnsureSession: { _, _ in }, + onDeliverMessage: { node, message, _ in + 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"))) + 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.maxNotes + 5) { + await store.handle(.artifactoryPost(text: "post \(index)", topic: nil, from: ids[0])) + } + + let graph = await store.graph + #expect(graph.artifactory.count == Artifactory.maxNotes) + #expect(graph.artifactory.first?.body == "post 5") + #expect(graph.artifactory.last?.id == Artifactory.maxNotes + 5) + } + + @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 + + /// 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 deletingALoopKeepsItsPostsAndTakesTheirHandle() async { + let store = await makeStore() + let ids = nodeIDs(await store.graph) + await store.handle(.artifactoryPost(text: "mine", topic: nil, from: ids[0])) + await store.handle(.artifactoryPost(text: "theirs", topic: nil, from: ids[1])) + + await store.handle(.deleteNode(ids[0])) + + let graph = await store.graph + #expect(graph.nodes.count == 1) + #expect(graph.artifactory.map(\.body) == ["mine", "theirs"]) + let orphaned = graph.artifactory[0] + #expect(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 + 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 deletingALoopOrphansItsSpawnedDescendantsPostsToo() 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 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)"]) + } +} + +/// 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"))) + } +} diff --git a/graphcode/Tests/FeatureRampsTests.swift b/graphcode/Tests/FeatureRampsTests.swift index d24650bf..31a7059f 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 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(.artifactory, configuration: nil, channel: "beta", installID: id)) + #expect( + !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: ["artifactory": ["beta": 100, "stable": 100]]) + #expect( + FeatureRamps.isEnabled( + .artifactory, configuration: everywhere, channel: "stable", installID: id)) + let nowhere = FeatureRamps.Configuration(features: ["artifactory": ["beta": 0, "stable": 0]]) + #expect( + !FeatureRamps.isEnabled( + .artifactory, configuration: nowhere, channel: "beta", installID: id)) + } } diff --git a/graphcode/Tests/SettingsArtifactoryTests.swift b/graphcode/Tests/SettingsArtifactoryTests.swift new file mode 100644 index 00000000..67b7aee8 --- /dev/null +++ b/graphcode/Tests/SettingsArtifactoryTests.swift @@ -0,0 +1,70 @@ +import Foundation +import Testing + +@testable import graphcode + +/// 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 SettingsArtifactoryTests { + private func resolution( + loaded: Bool, choice: Bool?, rampedOn: Bool + ) -> SettingsModel.ArtifactoryResolution { + SettingsModel.resolvesArtifactory( + 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.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.ArtifactoryResolution( + 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.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.ArtifactoryResolution( + 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.ArtifactoryResolution( + 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.ArtifactoryResolution( + enabled: false, fileNeedsWrite: true, showsSwitch: false)) + } +}