diff --git a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift index ee94fb9c..6928466d 100644 --- a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift +++ b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift @@ -11,8 +11,8 @@ import Foundation /// one file. python3 is already the launch path's scripting dependency (the Copilot /// trust seed), it's validated at add-connection time the same way `zmx` is, and the /// wire protocol it has to speak is four bytes of length plus JSON. The shim covers the -/// verbs the briefing teaches — create, send, memo, status — and says so for the rest, -/// rather than half-implementing all of them. +/// verbs the briefing teaches — create, send, memo, status, artifactory — and says so +/// for the rest, rather than half-implementing all of them. /// /// **Paths are `~/`-relative on purpose.** Nothing local knows the remote home /// directory, and every consumer expands them on the remote host itself: the installer @@ -110,7 +110,7 @@ public enum RemoteGraphAccess { /// a `graphcode` owned by another uid, a devcontainer feature's own copy, `chattr +i`. /// *Not* a full disk, though that was the first guess: on a genuinely full volume both /// writes fail and nothing is stranded, so it takes the freak case of enough free - /// blocks for a 12-byte stamp but not a 14.6 KB shim. + /// blocks for a 12-byte stamp but not a 31 KB shim. /// /// Ordering alone is sufficient — no `with`/`flush` bookkeeping — because a payload /// this size raises at `.write()` rather than at an implicit close, leaving no file @@ -158,6 +158,7 @@ public enum RemoteGraphAccess { import struct import sys import time + import unicodedata import uuid SESSION_PREFIX = "graphcode-" @@ -173,6 +174,11 @@ public enum RemoteGraphAccess { graphcode node delete irreversible; stop is reversible graphcode node send graphcode node memo + graphcode artifactory post [--topic ] + graphcode artifactory sync [--headlines] [--full] [--mark] [--json] + graphcode artifactory read + graphcode artifactory list [--search ] [--json] + graphcode artifactory watch [--topic ] [--off] SAFETY Use `graphcode projects` to discover paths and `graphcode status` before retrying. @@ -181,6 +187,12 @@ public enum RemoteGraphAccess { `graphcode reap` recovery runs on the Mac, not this remote host: use it only there, and run `graphcode reap --dry-run` before the destructive form. + ARTIFACTORY + The shared board any loop can post to and any loop can read -- check it at the + start of a pass. `sync` and `watch` are the calling loop's, so they need a + session ($ZMX_SESSION); `list` and `read` are read-only and move no cursor. A + large backlog prints as headlines and says so -- deep-read with `read `. + NODE OPTIONS --into create inside that composite's sub-graph --check what a human verifies each turn (--type turn) @@ -343,6 +355,209 @@ public enum RemoteGraphAccess { return flags + # The board's own arithmetic, ported from ArtifactoryKit rather than asked for over the + # wire: `openProject`'s snapshot already carries every post and every reader's cursor, so + # `read` and `list` send no command at all and `sync` prints from the snapshot it took + # before advancing the cursor. RemoteCLIShimTests asserts this renderer byte-equal against + # GraphcodeCommand's, which is the only thing that makes a second copy of it safe to have. + TRIAGE_AFTER_POSTS = 12 + TRIAGE_AFTER_BYTES = 4096 + # Foundation encodes Date as seconds since 2001-01-01, not since the epoch. + REFERENCE_DATE_OFFSET = 978307200 + # Spelled out rather than left to strftime("%b"), which follows the remote host's + # LC_TIME; the Swift side pins en_US_POSIX. + MONTH_ABBREVIATIONS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") + EM_DASH = "\u2014" + ELLIPSIS = "\u2026" + HEADLINE_BUDGET = 80 + + + def artifactory_unread(posts, last_read): + if last_read is None: + return list(posts) + return [post for post in posts if post.get("id", 0) > last_read] + + + def artifactory_needs_triage(posts): + if len(posts) > TRIAGE_AFTER_POSTS: + return True + weight = sum(len((post.get("body") or "").encode("utf-8")) for post in posts) + return weight > TRIAGE_AFTER_BYTES + + + def artifactory_cursor(graph, reader): + # (cursor, the graph knows this reader). The distinction is the status line's: a + # foreign or stale id gets the plain count, never "0 unread for you". + for node in graph.get("nodes") or []: + if str(node.get("id") or "").upper() == reader: + return node.get("lastArtifactoryRead"), True + return None, False + + + def post_stamp(at): + moment = time.localtime((at or 0) + REFERENCE_DATE_OFFSET) + return "%s %d, %02d:%02d" % (MONTH_ABBREVIATIONS[moment.tm_mon - 1], + moment.tm_mday, moment.tm_hour, moment.tm_min) + + + def render_post(post): + # Presence, not truthiness: Swift maps over the Optional, so a post whose topic + # is the empty string renders " ()". The daemon refuses one today; matching the + # Optional exactly is what keeps that a daemon rule rather than a second rule + # this renderer would have to be re-audited against if it ever moved. + topic = (" (%s)" % post["topic"]) if post.get("topic") is not None else "" + return "#%s%s from %s at %s %s %s" % ( + post.get("id"), topic, post.get("author"), post_stamp(post.get("at")), + EM_DASH, post.get("body") or "") + + + def grapheme_clusters(text): + # Swift measures and slices a String in extended grapheme clusters, Python in + # code points, so `text[:80]` would cut a headline early -- 15 characters into a + # body of decomposed accents, where the Mac cuts at 30 -- and could land between + # a base character and its combining mark, ending the line on a mangled glyph. + # This is UAX #29 reduced to the joins that actually reach a note: combining + # marks, ZWJ sequences, variation selectors, skin-tone modifiers and flags. The + # parity test drives each of them through both renderers. + clusters = [] + joining = False + flag_open = False + for character in text: + code = ord(character) + regional = 0x1F1E6 <= code <= 0x1F1FF + zero_width_joiner = code == 0x200D + extends = (unicodedata.category(character) in ("Mn", "Mc", "Me") + or 0xFE00 <= code <= 0xFE0F + or 0xE0100 <= code <= 0xE01EF + or 0x1F3FB <= code <= 0x1F3FF) + attaches = extends or zero_width_joiner or joining or (regional and flag_open) + if clusters and attaches: + clusters[-1] += character + else: + clusters.append(character) + joining = zero_width_joiner + flag_open = regional and not flag_open + return clusters + + + def render_headline(post): + full = render_post(post).replace("\n", " ") + clusters = grapheme_clusters(full) + if len(clusters) <= HEADLINE_BUDGET: + return full + return "".join(clusters[:HEADLINE_BUDGET]) + ELLIPSIS + + + def folded(text): + # Swift's String.contains compares canonically, so "e" + U+0301 and U+00E9 match + # there and would not here: Python's `in` is a code-point test. A body carrying + # decomposed text -- which anything sourced from a macOS path routinely does -- + # would otherwise be findable from the Mac and invisible from the remote host, + # which is the board reporting that mail does not exist. + return unicodedata.normalize("NFC", (text or "").lower()) + + + def filtered_posts(posts, search): + if not search: + return posts + needle = folded(search) + return [post for post in posts + if needle in folded(post.get("body")) + or needle in folded(post.get("author")) + or needle in folded(post.get("topic"))] + + + def render_board(graph, reader=None, headlines=False, search=None, auto_triage=False): + project = graph.get("project") or {} + posts = graph.get("artifactory") or [] + if reader is not None: + cursor, _ = artifactory_cursor(graph, reader) + posts = artifactory_unread(posts, cursor) + posts = filtered_posts(posts, search) + if not posts: + if search: + if reader is None: + return "no posts match '%s'" % search + return "no unread posts match '%s'" % search + if reader is not None: + return "no unread posts" + return ("the board is empty %s post one: graphcode artifactory post " + " " % (EM_DASH, ELLIPSIS)) + triaged = auto_triage and artifactory_needs_triage(posts) + label = "artifactory" if reader is None else "artifactory, unread" + header = "%s %s: %d post%s" % (project.get("name", "?"), label, len(posts), + "" if len(posts) == 1 else "s") + if triaged: + header += (" %s headlines only, that is a lot to read at once. Full text: " + "graphcode artifactory read %s " + % (EM_DASH, project.get("path", ""))) + lines = [header] + for post in posts: + lines.append(" " + (render_headline(post) if headlines or triaged + else render_post(post))) + return "\n".join(lines) + + + def iso8601(at): + moment = time.gmtime((at or 0) + REFERENCE_DATE_OFFSET) + return "%04d-%02d-%02dT%02d:%02d:%02dZ" % ( + moment.tm_year, moment.tm_mon, moment.tm_mday, + moment.tm_hour, moment.tm_min, moment.tm_sec) + + + def encoded_post(post): + # What JSONEncoder makes of an ArtifactoryPost: absent rather than null for the + # optionals, ISO-8601 for the date, and `kind` defaulted the way the hand-written + # decoder defaults it for boards saved before records had their own quota. + encoded = {"id": post.get("id"), "at": iso8601(post.get("at")), + "author": post.get("author"), "body": post.get("body"), + "kind": post.get("kind") or "note"} + if post.get("authorID") is not None: + encoded["authorID"] = post["authorID"] + if post.get("topic") is not None: + encoded["topic"] = post["topic"] + return encoded + + + def render_board_json(graph, reader=None, search=None): + posts = graph.get("artifactory") or [] + last_read = None + if reader is not None: + last_read, _ = artifactory_cursor(graph, reader) + posts = artifactory_unread(posts, last_read) + board = {"posts": [encoded_post(post) for post in filtered_posts(posts, search)]} + if last_read is not None: + board["lastRead"] = last_read + # Swift's JSONEncoder escapes forward slashes and emits non-ASCII raw; json.dumps + # does neither by default. `/` cannot occur outside a string in JSON, so escaping + # the dumped text wholesale is exact. A body carrying a path or a URL is what makes + # this visible, which is why the parity fixture has one. + encoded = json.dumps(board, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return encoded.replace("/", "\\/") + + + def artifactory_status_line(graph, reader): + posts = graph.get("artifactory") or [] + if not posts: + return None + plural = "" if len(posts) == 1 else "s" + cursor, known = artifactory_cursor(graph, reader) if reader else (None, False) + if not known: + return "artifactory: %d post%s" % (len(posts), plural) + return "artifactory: %d post%s, %d unread for you" % ( + len(posts), plural, len(artifactory_unread(posts, cursor))) + + + def render_posted(graph): + posts = graph.get("artifactory") or [] + if not posts: + return "posted" + post = posts[-1] + topic = (" (%s)" % post["topic"]) if post.get("topic") is not None else "" + return "posted #%s%s" % (post.get("id"), topic) + + def render(graph): project = graph.get("project") or {} lines = [str(project.get("name", "?"))] @@ -355,6 +570,13 @@ public enum RemoteGraphAccess { state = next(iter(state), "?") lines.append(" %s %s %s %s" % ( node.get("id"), state, node.get("loopType"), node.get("title"))) + # The board rides last: one line, only when there is anything on it, so a + # project that never touched the Artifactory renders as it always did. This is + # the cheap "is there mail I should care about" check the briefing sends every + # loop to `status` for, and the snapshot already holds everything it needs. + board = artifactory_status_line(graph, self_node_id()) + if board: + lines.append(" " + board) return "\n".join(lines) @@ -452,6 +674,20 @@ public enum RemoteGraphAccess { print(acknowledgement) + def run_and_report(project, inner, report): + # `run_with_verdict` with the acknowledgement computed from the graph that comes + # back rather than fixed in advance -- what `artifactory post` needs to name the + # sequence number the note landed at. + daemon = Daemon() + project = resolve_project(daemon, project) + daemon.open_project(project) + daemon.send(graph_command(project, inner)) + key, value = daemon.wait_for(["graphChanged", "errorOccurred"]) + if key == "errorOccurred": + fail(value["_0"]) + print(report(value["_0"])) + + def folded_title(raw): # The one-word CamelCase shape every loop name has -- LoopName.folded on the Mac. words = [] @@ -531,6 +767,137 @@ public enum RemoteGraphAccess { return bool(arguments) and arguments[0] in HELP_FLAGS + ARTIFACTORY_FLAGS = { + "post": ("topic",), + "sync": ("headlines", "mark", "json", "full"), + "read": (), + "list": ("search", "json"), + "watch": ("topic", "off"), + } + + + def artifactory_post_id(raw): + # One-based by construction -- the daemon's ids start at 1 -- so "-7" is a typo, + # never a post, and says so here rather than at the lookup. + digits = raw[1:] if raw[:1] in ("+", "-") else raw + if digits and all(character in "0123456789" for character in digits): + value = int(raw) + if value >= 1: + return value + fail("invalid value for post-id: %s" % raw) + + + def artifactory(arguments): + # Parsed in GraphcodeCommand.parseArtifactory's order -- subcommand, project path, + # help anywhere, then the flags that subcommand allows -- so a mistyped flag is + # refused here rather than silently ignored on the way to the daemon. + if not arguments: + fail("missing artifactory subcommand") + subverb = arguments.pop(0) + if wants_help(arguments): + print(HELP) + return + if not arguments or arguments[0].startswith("--"): + fail("missing project-path") + project = arguments.pop(0) + if any(argument in HELP_FLAGS for argument in arguments): + print(HELP) + return + if subverb not in ARTIFACTORY_FLAGS: + fail("unknown command: artifactory %s" % subverb) + for argument in arguments: + if argument.startswith("--") and argument[2:] not in ARTIFACTORY_FLAGS[subverb]: + fail("unknown option: %s" % argument) + flags = parse_flags(arguments) + + if subverb == "post": + # The note is joined argv words, the node send/memo bargain. A trailing + # `--topic` with no value goes with the flag: it was never the note's text. + words = list(arguments) + if "--topic" in words: + index = words.index("--topic") + del words[index:index + 2] + text = " ".join(words).strip() + if not text: + fail("missing note") + payload = {"text": text, "topic": flags.get("topic"), "from": self_node_id()} + run_and_report(project, {"artifactoryPost": payload}, render_posted) + return + + if subverb == "sync": + reader = self_node_id() + if not reader: + fail("artifactory sync needs a loop identity %s run it from inside a loop's " + "session ($ZMX_SESSION); a human reading the board wants `graphcode " + "artifactory list`" % EM_DASH) + daemon = Daemon() + project = resolve_project(daemon, project) + # Unread is computed from the snapshot taken *before* the cursor moves; reading + # it afterwards would report every post as read. Same one-round-trip race the + # Swift CLI documents and accepts. + graph = daemon.open_project(project) + daemon.send(graph_command(project, {"artifactorySync": {"from": reader}})) + key, value = daemon.wait_for(["graphChanged", "errorOccurred"]) + if key == "errorOccurred": + fail(value["_0"]) + headlines = "headlines" in flags + full = "full" in flags + if "json" in flags: + print(render_board_json(graph, reader=reader)) + elif "mark" in flags: + posts = graph.get("artifactory") or [] + latest = posts[-1].get("id", 0) if posts else 0 + if latest > 0: + print("marked read up to #%d" % latest) + else: + print("marked read %s the board is empty" % EM_DASH) + else: + print(render_board(graph, reader=reader, headlines=headlines, + auto_triage=not headlines and not full)) + return + + if subverb == "read": + if not arguments or arguments[0].startswith("--"): + fail("missing post-id") + post_id = artifactory_post_id(arguments[0]) + daemon = Daemon() + project = resolve_project(daemon, project) + graph = daemon.open_project(project) + for post in graph.get("artifactory") or []: + if post.get("id") == post_id: + print(render_post(post)) + return + fail("no post #%d on this board %s `graphcode artifactory list %s` shows the " + "ids that exist" % (post_id, EM_DASH, project)) + + if subverb == "list": + daemon = Daemon() + project = resolve_project(daemon, project) + graph = daemon.open_project(project) + if "json" in flags: + print(render_board_json(graph, search=flags.get("search"))) + else: + print(render_board(graph, search=flags.get("search"))) + return + + watcher = self_node_id() + if not watcher: + fail("artifactory watch needs a loop identity %s run it from inside a loop's " + "session ($ZMX_SESSION); the mail is delivered to the loop that watches" + % EM_DASH) + on = "off" not in flags + topic = flags.get("topic") + if not on: + acknowledgement = "stopped watching" + elif topic is None: + acknowledgement = ("watching all posts %s they are typed in when the loop goes " + "idle" % EM_DASH) + else: + acknowledgement = ("watching '%s' %s matching posts are typed in when the loop " + "goes idle" % (topic, EM_DASH)) + run_with_verdict(project, {"artifactoryWatch": {"on": on, "topic": topic, + "from": watcher}}, acknowledgement) + def main(arguments): if not arguments or arguments[0] in ("help", "-h", "--help"): print(HELP) @@ -554,6 +921,9 @@ public enum RemoteGraphAccess { fail("missing project-path") run_and_print(arguments[0]) return + if verb == "artifactory": + artifactory(arguments) + return if verb != "node": fail("unknown or Mac-only command: %s (see `graphcode help`)" % verb) if len(arguments) < 2: diff --git a/graphcode/Tests/RemoteCLIShimTests.swift b/graphcode/Tests/RemoteCLIShimTests.swift index 112ba0de..0ab29bbe 100644 --- a/graphcode/Tests/RemoteCLIShimTests.swift +++ b/graphcode/Tests/RemoteCLIShimTests.swift @@ -1,3 +1,4 @@ +import ArtifactoryKit import Foundation import Testing @@ -140,7 +141,7 @@ struct RemoteCLIShimTests { /// that answers every decoded command with a `graphChanged` — the acknowledgement /// shape the real daemon uses. private func runShim( - _ arguments: [String], environment: [String: String] = [:] + _ arguments: [String], environment: [String: String] = [:], graph: LoopGraph? = nil ) throws -> ShimRun { // Under `/tmp` rather than `NSTemporaryDirectory()`, whose per-user path can push // the socket past `sun_path`'s 104 bytes. @@ -174,7 +175,7 @@ struct RemoteCLIShimTests { let client = accept(listener, nil, nil) guard client >= 0 else { return } defer { close(client) } - let graph = LoopGraph(project: ProjectRef(path: Self.project, name: "widget")) + let graph = graph ?? LoopGraph(project: ProjectRef(path: Self.project, name: "widget")) while let data = try? FramedMessageIO.readFrame(from: client) { guard let command = try? JSONDecoder().decode(DaemonCommand.self, from: data) else { return } @@ -218,3 +219,437 @@ struct RemoteCLIShimTests { commands: received.all) } } + +/// The board verbs, split into their own extension: `RemoteCLIShimTests` sits at +/// swiftlint's 350-line `type_body_length` error without them. +extension RemoteCLIShimTests { + /// The board the parity tests drive: a cursor that leaves one post read, a topic and + /// a bare byline, a body long enough that a headline has to cut it, and one carrying + /// the slashes and the em dash that separate Swift's JSON escaping from Python's. + /// Dates are fixed so the reference-date conversion is pinned, not merely exercised. + private static func board() -> (LoopGraph, LoopNode) { + var graph = LoopGraph(project: ProjectRef(path: project, name: "widget")) + var reader = LoopNode(title: "Reader", loopType: .goalBased) + reader.lastArtifactoryRead = 1 + graph.nodes.append(reader) + graph.artifactory = [ + ArtifactoryPost( + id: 1, at: Date(timeIntervalSince1970: 1_756_000_000), authorID: nil, + author: "a human", topic: nil, body: "already read"), + ArtifactoryPost( + id: 2, at: Date(timeIntervalSince1970: 1_756_003_600), authorID: UUID(), + author: "BuildWatch", topic: "build", + body: String(repeating: "the gate is red ", count: 12)), + ArtifactoryPost( + id: 3, at: Date(timeIntervalSince1970: 1_756_007_200), authorID: nil, + author: "a human", topic: nil, + // A path and a URL on purpose: Swift's JSONEncoder escapes `/` as `\/` and + // Python's json.dumps does not, so a fixture without one lets `--json` drift + // apart silently. This is the body that catches it. + body: "claiming issue #12 — see docs/281.md and https://example.test/a/b", + kind: .record), + ] + return (graph, reader) + } + + /// The renderer is duplicated — Swift on the Mac, Python on the remote host — and a + /// defective copy propagates to every remote host on the next ensure, because + /// `cliShimStamp` is content-derived. Byte equality against the production Swift + /// renderer, through the production shim source, is the only thing that makes the + /// duplication safe; nothing here compares against a string written by hand. + @Test + func theBoardRendersByteEqualToTheSwiftRenderer() throws { + let (graph, reader) = Self.board() + let session = ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"] + + let cases: [(arguments: [String], environment: [String: String], expected: String)] = [ + (["artifactory", "list", Self.project], [:], GraphcodeCommand.renderArtifactory(graph)), + ( + ["artifactory", "list", Self.project, "--search", "RED"], [:], + GraphcodeCommand.renderArtifactory(graph, search: "RED") + ), + ( + ["artifactory", "list", Self.project, "--search", "nothing-matches"], [:], + GraphcodeCommand.renderArtifactory(graph, search: "nothing-matches") + ), + ( + ["artifactory", "sync", Self.project], session, + GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id, autoTriage: true) + ), + ( + ["artifactory", "sync", Self.project, "--headlines"], session, + GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id, headlines: true) + ), + ( + ["artifactory", "sync", Self.project, "--full"], session, + GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id) + ), + ( + ["artifactory", "read", Self.project, "2"], [:], + GraphcodeCommand.render(graph.artifactory[1]) + ), + ( + ["artifactory", "list", Self.project, "--json"], [:], + GraphcodeCommand.renderArtifactoryJSON(graph) + ), + ( + ["artifactory", "sync", Self.project, "--json"], session, + GraphcodeCommand.renderArtifactoryJSON(graph, unreadFor: reader.id) + ), + ] + + for testCase in cases { + let run = try runShim(testCase.arguments, environment: testCase.environment, graph: graph) + #expect(run.status == 0, "\(testCase.arguments) failed: \(run.stderr)") + #expect( + run.stdout == testCase.expected + "\n", + "\(testCase.arguments)\nshim: \(run.stdout)\nswift: \(testCase.expected)") + } + } + + /// A board with nothing on it, and a caught-up reader, are the two shapes the parity + /// fixture cannot reach — and both are what a loop meets on its very first pass. + @Test + func anEmptyBoardAndACaughtUpReaderRenderByteEqualToo() throws { + var graph = LoopGraph(project: ProjectRef(path: Self.project, name: "widget")) + var reader = LoopNode(title: "Reader", loopType: .goalBased) + reader.lastArtifactoryRead = 3 + graph.nodes.append(reader) + + let empty = try runShim(["artifactory", "list", Self.project], graph: graph) + #expect(empty.stdout == GraphcodeCommand.renderArtifactory(graph) + "\n") + + graph.artifactory = [ + ArtifactoryPost( + id: 3, at: Date(timeIntervalSince1970: 1_756_000_000), authorID: nil, + author: "a human", topic: nil, body: "caught up") + ] + let synced = try runShim( + ["artifactory", "sync", Self.project], + environment: ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"], graph: graph) + #expect( + synced.stdout + == GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id, autoTriage: true) + "\n") + #expect(synced.stdout == "no unread posts\n") + } + + /// `at` crosses the wire as a Foundation reference-date interval — seconds since + /// 2001-01-01, not since the epoch — so a shim that formatted it as epoch would print + /// a date 31 years early. Pinned against the formatter the Swift CLI actually uses. + @Test + func theStampConvertsFromFoundationsReferenceDate() throws { + let (graph, _) = Self.board() + let stamp = ArtifactoryPost.stampFormat.string(from: graph.artifactory[0].at) + let run = try runShim(["artifactory", "read", Self.project, "1"], graph: graph) + #expect(run.stdout == "#1 from a human at \(stamp) — already read\n") + + // The stamp cannot carry the whole guard on its own. `MMM d, HH:mm` has no year, + // and the offset is 978307200 seconds — exactly 11323 days, which is exactly 31 + // years across this span's eight leap days — so dropping it entirely renders the + // *identical* stamp, "Aug 23, 18:46" either way. The year rides `--json`'s + // ISO-8601 instead, where the same mistake cannot hide. + let json = try runShim(["artifactory", "list", Self.project, "--json"], graph: graph) + #expect(json.stdout.contains("\"at\":\"2025-08-24T01:46:40Z\"")) + #expect(!json.stdout.contains("\"at\":\"1994-")) + } + + /// The triage boundary, both halves of it: `Artifactory.needsTriage` is more than 12 + /// posts *or* more than 4096 bytes of body, and a sync that trips either one prints + /// headlines and says so. Off-by-one here silently truncates a board a loop was told + /// it had read in full. + @Test + func triageTripsAtTwelvePostsAndAtFourKilobytes() throws { + func syncOutput(posts: Int, bodyBytes: Int) throws -> (String, String) { + var graph = LoopGraph(project: ProjectRef(path: Self.project, name: "widget")) + let reader = LoopNode(title: "Reader", loopType: .goalBased) + graph.nodes.append(reader) + let each = bodyBytes / posts + graph.artifactory = (1...posts).map { index in + ArtifactoryPost( + id: index, at: Date(timeIntervalSince1970: 1_756_000_000 + Double(index)), + authorID: nil, author: "a human", topic: nil, + body: String( + repeating: "x", count: index == posts ? bodyBytes - each * (posts - 1) : each) + ) + } + let run = try runShim( + ["artifactory", "sync", Self.project], + environment: ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"], graph: graph) + return ( + run.stdout, + GraphcodeCommand.renderArtifactory(graph, unreadFor: reader.id, autoTriage: true) + "\n" + ) + } + + // Twelve posts well under the byte cap: full bodies, no announcement. + let (twelve, twelveSwift) = try syncOutput(posts: 12, bodyBytes: 120) + #expect(twelve == twelveSwift) + #expect(!twelve.contains("headlines only")) + + let (thirteen, thirteenSwift) = try syncOutput(posts: 13, bodyBytes: 130) + #expect(thirteen == thirteenSwift) + #expect(thirteen.contains("headlines only")) + + // Bytes, at the boundary: 4096 is not "more than", 4097 is. + let (atCap, atCapSwift) = try syncOutput(posts: 8, bodyBytes: 4096) + #expect(atCap == atCapSwift) + #expect(!atCap.contains("headlines only")) + + let (overCap, overCapSwift) = try syncOutput(posts: 8, bodyBytes: 4097) + #expect(overCap == overCapSwift) + #expect(overCap.contains("headlines only")) + } + + /// `status` is where the briefing sends a loop before it claims work, and the board + /// line is what makes "is there mail I should care about" free. It was absent from + /// the shim's own render even though the snapshot carrying it was already in hand. + @Test + func statusCarriesTheBoardLineTheLocalCLIPrints() throws { + let (graph, reader) = Self.board() + + let asReader = try runShim( + ["status", Self.project], + environment: ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"], graph: graph) + let readerLine = try #require( + GraphcodeCommand.renderArtifactoryStatusLine(graph, readerID: reader.id)) + #expect(readerLine == "artifactory: 3 posts, 2 unread for you") + #expect(asReader.stdout.hasSuffix(" " + readerLine + "\n")) + + // A human shell, and a loop this graph has never heard of, both get the plain + // count — the daemon would refuse a cursor for either. + let asHuman = try runShim(["status", Self.project], graph: graph) + let plain = try #require(GraphcodeCommand.renderArtifactoryStatusLine(graph)) + #expect(plain == "artifactory: 3 posts") + #expect(asHuman.stdout.hasSuffix(" " + plain + "\n")) + + let asStranger = try runShim( + ["status", Self.project], + environment: ["ZMX_SESSION": "graphcode-\(UUID().uuidString)"], graph: graph) + #expect(asStranger.stdout.hasSuffix(" " + plain + "\n")) + + // A project that never touched the board renders exactly as it did before. + let untouched = try runShim( + ["status", Self.project], + graph: LoopGraph(project: ProjectRef(path: Self.project, name: "widget"))) + #expect(!untouched.stdout.contains("artifactory")) + } + + @Test + func postSyncAndWatchRideTheWireExactlyAsTheSwiftCLIWould() throws { + let (graph, reader) = Self.board() + let session = ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"] + + let posted = try runShim( + ["artifactory", "post", Self.project, "--topic", "claims", "issue", "#12", "is", "mine"], + environment: session, graph: graph) + #expect(posted.status == 0) + #expect(posted.stdout == GraphcodeCommand.renderPosted(graph) + "\n") + #expect( + posted.commands.dropFirst().first + == .graphCommand( + projectPath: Self.project, + command: .artifactoryPost(text: "issue #12 is mine", topic: "claims", from: reader.id))) + + // A human's post is unattributed, exactly as `node send` from a shell is. + let byHuman = try runShim( + ["artifactory", "post", Self.project, "the", "board", "is", "for", "everyone"], graph: graph) + #expect( + byHuman.commands.dropFirst().first + == .graphCommand( + projectPath: Self.project, + command: .artifactoryPost( + text: "the board is for everyone", topic: nil, from: nil))) + + let synced = try runShim( + ["artifactory", "sync", Self.project, "--mark"], environment: session, graph: graph) + #expect(synced.stdout == "marked read up to #3\n") + #expect( + synced.commands.dropFirst().first + == .graphCommand(projectPath: Self.project, command: .artifactorySync(from: reader.id))) + + let watching = try runShim( + ["artifactory", "watch", Self.project, "--topic", "build"], environment: session, + graph: graph) + #expect(watching.stdout.hasPrefix("watching 'build' —")) + #expect( + watching.commands.dropFirst().first + == .graphCommand( + projectPath: Self.project, + command: .artifactoryWatch(on: true, topic: "build", from: reader.id))) + + let unwatching = try runShim( + ["artifactory", "watch", Self.project, "--off"], environment: session, graph: graph) + #expect(unwatching.stdout == "stopped watching\n") + #expect( + unwatching.commands.dropFirst().first + == .graphCommand( + projectPath: Self.project, + command: .artifactoryWatch(on: false, topic: nil, from: reader.id))) + } + + /// Swift measures a headline in extended grapheme clusters and Python in code points, + /// so the cut drifts apart on anything built out of combining sequences — and can land + /// between a base character and its mark, ending a remote headline on a mangled glyph. + /// Every join that actually reaches a note, driven through both renderers. + @Test + func headlinesCutAtTheSameGraphemeClusterTheMacDoes() throws { + let bodies = [ + // Decomposed accents: 2 code points each, 1 character each. + String(repeating: "a\u{0301}", count: 70), + // ZWJ family: 7 code points, 1 character. + String(repeating: "👨‍👩‍👧‍👦", count: 20), + // Skin-tone modifier, regional-indicator flag, and a keycap sequence. + String(repeating: "👋🏽🇯🇵1️⃣", count: 20), + // Mixed, so the cut lands mid-sequence rather than tidily between them. + "release notes: " + String(repeating: "e\u{0301}👨‍👩‍👧‍👦x", count: 20), + // Astral without any joining, and text that needs no clustering at all. + String(repeating: "𝔊", count: 90), + String(repeating: "plain ascii ", count: 12), + ] + + for (index, body) in bodies.enumerated() { + var graph = LoopGraph(project: ProjectRef(path: Self.project, name: "widget")) + graph.artifactory = [ + ArtifactoryPost( + id: 1, at: Date(timeIntervalSince1970: 1_756_000_000), authorID: nil, + author: "a human", topic: nil, body: body) + ] + let reader = LoopNode(title: "Reader", loopType: .goalBased) + graph.nodes.append(reader) + + let run = try runShim( + ["artifactory", "sync", Self.project, "--headlines"], + environment: ["ZMX_SESSION": "graphcode-\(reader.id.uuidString)"], graph: graph) + let expected = GraphcodeCommand.renderArtifactory( + graph, unreadFor: reader.id, headlines: true) + #expect(run.stdout == expected + "\n", "body \(index) cut differently") + } + } + + /// `--search` is how a loop asks whether mail on a subject exists. Swift's + /// `String.contains` compares canonically, so a decomposed body matches a precomposed + /// needle on the Mac; a code-point test would answer "no posts match" remotely and + /// report that the mail does not exist. + @Test + func searchMatchesCanonicallyEquivalentTextAsSwiftDoes() throws { + var graph = LoopGraph(project: ProjectRef(path: Self.project, name: "widget")) + graph.artifactory = [ + ArtifactoryPost( + id: 1, at: Date(timeIntervalSince1970: 1_756_000_000), authorID: nil, + author: "a human", topic: nil, body: "shipped the e\u{0301}clair build"), + ArtifactoryPost( + id: 2, at: Date(timeIntervalSince1970: 1_756_003_600), authorID: nil, + author: "Ame\u{0301}lie", topic: "cafe\u{0301}", body: "unrelated"), + ] + + // Precomposed needles against decomposed body, author and topic — and the reverse. + for needle in ["éclair", "e\u{0301}clair", "Amélie", "café", "ÉCLAIR"] { + let run = try runShim( + ["artifactory", "list", Self.project, "--search", needle], graph: graph) + let expected = GraphcodeCommand.renderArtifactory(graph, search: needle) + #expect(run.stdout == expected + "\n", "search '\(needle)' diverged") + #expect(!expected.hasPrefix("no posts match"), "fixture no longer exercises a match") + } + } + + /// The parser's remaining shape, matched to `parseArtifactory`: a `--flag` is never a + /// project path, and a topic is an Optional rather than a truthiness test — the empty + /// topic the daemon refuses today still has to render the way Swift renders it, or + /// this is a second rule the renderer would need re-auditing against if that moved. + @Test + func theParserAndTheEmptyTopicMatchTheSwiftCLIsShape() throws { + let missingPath = try runShim(["artifactory", "list", "--json"]) + #expect(missingPath.status == 1) + #expect(missingPath.stderr.contains("missing project-path")) + // Refused before the dial, so the daemon is never asked to open a project called + // "--json" — nothing reaches the graph to be undone. + #expect(missingPath.commands.isEmpty) + + var graph = LoopGraph(project: ProjectRef(path: Self.project, name: "widget")) + graph.artifactory = [ + ArtifactoryPost( + id: 5, at: Date(timeIntervalSince1970: 1_756_000_000), authorID: nil, + author: "a human", topic: "", body: "a topic that is present but empty") + ] + let run = try runShim(["artifactory", "read", Self.project, "5"], graph: graph) + #expect(run.stdout == GraphcodeCommand.render(graph.artifactory[0]) + "\n") + #expect(run.stdout.contains("#5 () from a human")) + + let posted = try runShim( + ["artifactory", "post", Self.project, "anything"], graph: graph) + #expect(posted.stdout == GraphcodeCommand.renderPosted(graph) + "\n") + #expect(posted.stdout == "posted #5 ()\n") + } + + /// `read` and `list` send nothing past the open — the snapshot already carries the + /// board — so neither can move a cursor by accident. + @Test + func readAndListSendNoCommandPastTheOpen() throws { + let (graph, _) = Self.board() + for arguments in [ + ["artifactory", "read", Self.project, "3"], ["artifactory", "list", Self.project], + ] { + let run = try runShim(arguments, graph: graph) + #expect(run.status == 0) + #expect(run.commands == [.openProject(path: Self.project)]) + } + } + + /// The cursor verbs refuse a human shell up front, in the Swift CLI's own wording, + /// rather than after a round trip — and `artifactory` no longer falls through to the + /// "Mac-only" refusal that made every verb on this list exit 1. + @Test + func theCursorVerbsNeedALoopIdentityAndArtifactoryIsNoLongerMacOnly() throws { + let sync = try runShim(["artifactory", "sync", Self.project]) + #expect(sync.status == 1) + #expect(sync.stderr.contains("artifactory sync needs a loop identity")) + #expect(sync.stderr.contains("graphcode artifactory list")) + + let watch = try runShim(["artifactory", "watch", Self.project]) + #expect(watch.status == 1) + #expect(watch.stderr.contains("artifactory watch needs a loop identity")) + #expect(watch.stderr.contains("the mail is delivered to the loop that watches")) + + // Neither reached the daemon, so nothing was applied and nothing needs undoing. + #expect(sync.commands.isEmpty) + #expect(watch.commands.isEmpty) + + for verb in ["post", "sync", "read", "list", "watch"] { + let run = try runShim(["artifactory", verb]) + #expect(!run.stderr.contains("Mac-only"), "artifactory \(verb) still refused as Mac-only") + } + // A subcommand that genuinely does not exist says so as the Swift CLI does. + let bogus = try runShim(["artifactory", "resolve", Self.project]) + #expect(bogus.status == 1) + #expect(bogus.stderr.contains("unknown command: artifactory resolve")) + // And a mistyped flag is refused rather than silently ignored. + let mistyped = try runShim(["artifactory", "list", Self.project, "--serach", "red"]) + #expect(mistyped.status == 1) + #expect(mistyped.stderr.contains("unknown option: --serach")) + } + + @Test + func readNamesTheIdsThatExistWhenThePostIsGone() throws { + let (graph, _) = Self.board() + let missing = try runShim(["artifactory", "read", Self.project, "99"], graph: graph) + #expect(missing.status == 1) + #expect(missing.stderr.contains("no post #99 on this board")) + #expect(missing.stderr.contains("graphcode artifactory list")) + + let negative = try runShim(["artifactory", "read", Self.project, "-7"], graph: graph) + #expect(negative.status == 1) + #expect(negative.stderr.contains("invalid value for post-id: -7")) + #expect(negative.commands.isEmpty) + } + + @Test + func theHelpTextTeachesEveryVerbTheBriefingDoes() throws { + let help = try runShim(["artifactory", "--help"]) + #expect(help.status == 0) + for verb in ["post", "sync", "read", "list", "watch"] { + #expect(help.stdout.contains("graphcode artifactory \(verb) ")) + } + // The shim's own honesty rule: nothing it implements may sit on the Mac-only list. + #expect(!help.stdout.contains("(update, pilot, arm, edge, usage, artifactory)")) + } +}