From 893ba326535599106369da9f7713d55308f309a2 Mon Sep 17 00:00:00 2001 From: scgopi Date: Fri, 4 Sep 2026 11:11:22 -0700 Subject: [PATCH 1/2] Teach the remote shim the Artifactory's five verbs A loop on an ssh:// or Codespaces host is handed the briefing's full "notes for whoever comes next" section, told to check the board at the start of every pass, and then refused by its CLI on post, sync, read, list and watch: the Python shim dispatches seven verbs and artifactory is not one of them. Its own node sends are mirrored onto that board by the daemon, so a remote loop was not absent from the Artifactory, it was write-only and blind -- visible to every peer, unable to read a word anyone said back. The refusal was entirely client-side. GraphCommand carries no host discrimination, GraphStore dispatches the three board commands unconditionally, and ssh -N -R forwards bytes without parsing frames, so nothing in DaemonProtocol, RemoteSocketForwarder or GraphStore changes here. read and list need no command at all: openProject's graphChanged snapshot already carries the whole board and every node's cursor. status gains the board line too. renderArtifactoryStatusLine has been on the local CLI's render since the board shipped, and the shim's render never had it -- so the cheap "is there mail" check the briefing sends every loop to before claiming work was silently absent remotely, with the snapshot it needed already in hand. The renderer is now duplicated, Swift on the Mac and Python on the remote host, and cliShimStamp is content-derived: a defective shim propagates to every remote host on the next ensure and breaks node send and memo for every loop already running there. What makes that safe is RemoteCLIShimTests asserting the two byte-equal -- real session, real shim source, production renderer, never a constructed string. It earned its keep twice: the reference-date offset is exactly 11323 days, which is exactly 31 years across the fixture's leap days, so a dropped offset renders an identical MMM d, HH:mm stamp and the year had to be pinned through ISO-8601 instead; and Swift's JSONEncoder escapes / as \/ where json.dumps does not, which the fixture now carries a path and a URL to catch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AoU8LMupkqeoemNSob7VuM --- .../Sources/Sessions/RemoteGraphAccess.swift | 333 ++++++++++++++++- graphcode/Tests/RemoteCLIShimTests.swift | 345 +++++++++++++++++- 2 files changed, 674 insertions(+), 4 deletions(-) diff --git a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift index ee94fb9c..6dbfd4c1 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 @@ -173,6 +173,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 +186,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 +354,169 @@ 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): + topic = (" (%s)" % post["topic"]) if post.get("topic") 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 render_headline(post): + # Python counts code points where Swift counts grapheme clusters, so a body built + # out of combining sequences could cut a character earlier or later than the Mac + # would. Everything else about the line is identical, and the parity test pins it. + full = render_post(post).replace("\n", " ") + if len(full) <= HEADLINE_BUDGET: + return full + return full[:HEADLINE_BUDGET] + ELLIPSIS + + + def filtered_posts(posts, search): + if not search: + return posts + needle = search.lower() + return [post for post in posts + if needle in (post.get("body") or "").lower() + or needle in (post.get("author") or "").lower() + or needle in (post.get("topic") or "").lower()] + + + 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") else "" + return "posted #%s%s" % (post.get("id"), topic) + + def render(graph): project = graph.get("project") or {} lines = [str(project.get("name", "?"))] @@ -355,6 +529,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 +633,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 +726,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: + 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 +880,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..87e07cfe 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,343 @@ 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))) + } + + /// `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)")) + } +} From b248f1a4f508871bc070b31de2672f8ac4e80d58 Mon Sep 17 00:00:00 2001 From: scgopi Date: Fri, 4 Sep 2026 11:33:45 -0700 Subject: [PATCH 2/2] Match the Swift renderer on Unicode and on the parser's shape Five findings from review of the byte-equal port, four of them real divergences the first fixture could not reach. `--search` compared code points where Swift's String.contains compares canonically, so a body carrying decomposed text -- anything sourced from a macOS path routinely does -- was findable from the Mac and invisible from the remote host. That is the board answering that mail does not exist. Both sides now fold through NFC. `renderHeadline`'s budget counted code points where Swift counts extended grapheme clusters. The first commit documented this as a character either way; measured, it cuts a body of decomposed accents at 15 characters against the Mac's 30, and can land between a base character and its combining mark or inside a ZWJ sequence, ending a remote headline on a mangled glyph. UAX #29 reduced to the joins that actually reach a note -- combining marks, ZWJ, variation selectors, skin-tone modifiers, flags -- now backs the cut, with every one of them driven through both renderers. A token starting with -- was accepted as the project path, so `artifactory list --json` dialled the daemon and asked it to open a project named --json instead of refusing up front the way parseArtifactory does. Now refused before any dial. A post's topic was tested for truthiness rather than presence, so an empty topic rendered without the `()` Swift's Optional map produces. The daemon refuses an empty topic today, which is exactly why the renderer should not carry a second opinion about it. Also: the deliveryFragment doc comment's stranded-receipt argument cited a 14.6 KB shim; it is 31 KB now. The argument holds, the figure did not. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AoU8LMupkqeoemNSob7VuM --- .../Sources/Sessions/RemoteGraphAccess.swift | 67 ++++++++++--- graphcode/Tests/RemoteCLIShimTests.swift | 94 +++++++++++++++++++ 2 files changed, 148 insertions(+), 13 deletions(-) diff --git a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift index 6dbfd4c1..6928466d 100644 --- a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift +++ b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift @@ -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-" @@ -401,30 +402,70 @@ public enum RemoteGraphAccess { def render_post(post): - topic = (" (%s)" % post["topic"]) if post.get("topic") else "" + # 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): - # Python counts code points where Swift counts grapheme clusters, so a body built - # out of combining sequences could cut a character earlier or later than the Mac - # would. Everything else about the line is identical, and the parity test pins it. full = render_post(post).replace("\n", " ") - if len(full) <= HEADLINE_BUDGET: + clusters = grapheme_clusters(full) + if len(clusters) <= HEADLINE_BUDGET: return full - return full[:HEADLINE_BUDGET] + ELLIPSIS + 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 = search.lower() + needle = folded(search) return [post for post in posts - if needle in (post.get("body") or "").lower() - or needle in (post.get("author") or "").lower() - or needle in (post.get("topic") or "").lower()] + 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): @@ -513,7 +554,7 @@ public enum RemoteGraphAccess { if not posts: return "posted" post = posts[-1] - topic = (" (%s)" % post["topic"]) if post.get("topic") else "" + topic = (" (%s)" % post["topic"]) if post.get("topic") is not None else "" return "posted #%s%s" % (post.get("id"), topic) @@ -756,7 +797,7 @@ public enum RemoteGraphAccess { if wants_help(arguments): print(HELP) return - if not arguments: + 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): diff --git a/graphcode/Tests/RemoteCLIShimTests.swift b/graphcode/Tests/RemoteCLIShimTests.swift index 87e07cfe..0ab29bbe 100644 --- a/graphcode/Tests/RemoteCLIShimTests.swift +++ b/graphcode/Tests/RemoteCLIShimTests.swift @@ -487,6 +487,100 @@ extension RemoteCLIShimTests { 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