Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 51 additions & 85 deletions GraphcodeKit/Sources/CLI/GraphcodeCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -849,120 +849,82 @@ extension GraphcodeCommand {
return projects.map { "\($0.name) \($0.path)" }.joined(separator: "\n")
}

/// The board for a terminal. `mail list` prints the whole thing (`reader`
/// nil); `mail inbox` passes the reading loop's id and prints only what its
/// cursor has not covered — the subtraction is `Mailroom.unread`, the arithmetic
/// the daemon's cursor contract rests on, so the CLI's "unread" and the store's can
/// never disagree. `headlines` truncates each body to a triage line's worth (the
/// deep read is `mail read <id>`); `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.
/// The room for a terminal, from the mailbox the daemon answered
/// (`DaemonCommand.mailbox`). `mail list` asks for the whole room (`unread` false);
/// `mail inbox` asks for what the reading loop's cursor has not covered. The
/// subtraction, the search and the triage all happened in the daemon, so what
/// arrives is exactly what prints — this only decides the words around it. A
/// mailbox whose bodies the room cut says so (`bodiesTrimmed`), and unless the
/// caller asked for headlines itself, the header tells the reader it is reading a
/// triaged room and where the full text is.
public static func renderMailroom(
_ graph: LoopGraph, unreadFor readerID: UUID? = nil, headlines: Bool = false,
search: String? = nil, autoTriage: Bool = false
_ mailbox: Mailbox, project: ProjectRef, unread: Bool, headlines: Bool = false,
search: String? = nil
) -> String {
var posts: [MailroomPost]
if let readerID {
posts = Mailroom.unread(
in: graph.mailroom, since: graph.nodes[id: readerID]?.lastMailroomRead)
} else {
posts = graph.mailroom
}
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 posts = mailbox.posts
guard !posts.isEmpty else {
if let search, !search.isEmpty {
return readerID == nil
? "no posts match '\(search)'"
: "no unread posts match '\(search)'"
return unread ? "no unread posts match '\(search)'" : "no posts match '\(search)'"
}
return readerID == nil
? "the room is empty — post one: graphcode mail post <project-path> <notice…>"
: "no unread posts"
return unread
? "no unread posts"
: "the room is empty — post one: graphcode mail post <project-path> <notice…>"
}
// `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 && Mailroom.needsTriage(posts)
let label = readerID == nil ? "mailroom" : "mailroom, unread"
var header =
"\(graph.project.name) \(label): \(posts.count) post\(posts.count == 1 ? "" : "s")"
let triaged = mailbox.bodiesTrimmed && !headlines
let label = unread ? "mailroom, unread" : "mailroom"
var header = "\(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 mail read \(graph.project.path) <post-id>"
+ "Full text: graphcode mail read \(project.path) <post-id>"
}
var lines = [header]
for post in posts {
lines.append(headlines || triaged ? " \(renderHeadline(post))" : " \(render(post))")
lines.append(
headlines || mailbox.bodiesTrimmed ? " \(renderHeadline(post))" : " \(render(post))")
}
return lines.joined(separator: "\n")
}

/// The board as one machine-readable object — the same posts `renderMailroom`
/// would print (the same `search` filter included, so `--search --json` shows a
/// filtered board, never quietly an unfiltered one), plus the reader's cursor so a
/// client can compute unread itself. Dates are ISO-8601, pinned by test — the
/// encoder's default (seconds since 2001) is a wire format only this process
/// should ever have to know about.
public static func renderMailroomJSON(
_ graph: LoopGraph, unreadFor readerID: UUID? = nil, search: String? = nil
) -> String {
/// The mailbox as one machine-readable object — the same posts `renderMailroom`
/// would print, 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 renderMailroomJSON(_ mailbox: Mailbox) -> String {
struct Board: Encodable {
var posts: [MailroomPost]
var lastRead: Int?
}
var posts: [MailroomPost]
if let readerID {
posts = Mailroom.unread(
in: graph.mailroom, since: graph.nodes[id: readerID]?.lastMailroomRead)
} else {
posts = graph.mailroom
}
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]?.lastMailroomRead }
let board = Board(posts: posts, lastRead: lastRead)
let board = Board(posts: mailbox.posts, lastRead: mailbox.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 Mailroom renders exactly
/// `status`'s one-line window onto the room: how many posts exist and — when the
/// caller is a loop with a cursor here — whether any are unread for it. `nil` when
/// the room is empty, so a project that never touched the Mailroom renders exactly
/// as it did before this line existed. The point is cost: the briefing already sends
/// loops to `status` before claiming or creating work, and this makes the "is there
/// mail I should know about" check ride along for free.
/// mail I should know about" check ride along for free — off the snapshot's digest,
/// without the posts themselves ever crossing the socket.
public static func renderMailroomStatusLine(
_ graph: LoopGraph, readerID: UUID? = nil
) -> String? {
guard !graph.mailroom.isEmpty else { return nil }
let total = graph.mailroom.count
let plural = total == 1 ? "" : "s"
// "Unread for you" needs a *you* this board knows: the daemon refuses sync for a
// reader absent from the graph, so the status line claims no unread for one
let digest = graph.boardDigest
guard !digest.isEmpty else { return nil }
let plural = digest.count == 1 ? "" : "s"
// "For you" needs a *you* this room knows: the daemon refuses the cursor advance
// for a reader absent from the graph, so the status line claims nothing 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 "mailroom: \(total) post\(plural)"
guard let readerID, let reader = graph.nodes[id: readerID] else {
return "mailroom: \(digest.count) post\(plural)"
}
let unread = Mailroom.unread(
in: graph.mailroom, since: graph.nodes[id: readerID]?.lastMailroomRead
).count
return "mailroom: \(total) post\(plural), \(unread) unread for you"
let unread = digest.latestID > (reader.lastMailroomRead ?? 0)
return "mailroom: \(digest.count) post\(plural), "
+ (unread ? "unread mail for you" : "nothing unread for you")
}

/// One post, one line — the same identification the daemon's wake nudge quotes, so
Expand Down Expand Up @@ -990,11 +952,15 @@ extension GraphcodeCommand {
}

/// `mail post`'s answer — the sequence number is what the author's own log and
/// any replier's `node send` can refer to the note by.
public static func renderPosted(_ graph: LoopGraph) -> String {
guard let post = graph.mailroom.last else { return "posted" }
let topic = post.topic.map { " (\($0))" } ?? ""
return "posted #\(post.id)\(topic)"
/// any replier's `node send` can refer to the note by. Read off the digest of the
/// graph the post came back on; `topic` is the one the caller typed, spelled the way
/// the daemon keeps it (trimmed, lower-cased, absent when blank).
public static func renderPosted(_ graph: LoopGraph, topic: String? = nil) -> String {
let latest = graph.boardDigest.latestID
guard latest > 0 else { return "posted" }
let spelled = topic?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let suffix = spelled.flatMap { $0.isEmpty ? nil : " (\($0))" } ?? ""
return "posted #\(latest)\(suffix)"
}

public static func describe(_ error: ParseError) -> String {
Expand Down
42 changes: 34 additions & 8 deletions GraphcodeKit/Sources/Domain/LoopGraph.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,25 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable {
public var scope: LoopGraphScope
public var nodes: IdentifiedArrayOf<LoopNode>
public var edges: IdentifiedArrayOf<LoopEdge>
/// The project's Mailroom — every post any loop has dropped onto the shared board,
/// oldest first, notes and mirrored records each capped on their own budget
/// The project's Mailroom — every post any loop has dropped onto the shared room,
/// oldest first, notices and mirrored letters each capped on their own budget
/// (`Mailroom.maxNotices`, `Mailroom.maxLetters`). Kept on the graph rather than in a
/// side store so it inherits for free everything graph state already has: one
/// writer (the daemon), atomic persistence beside the graph file, a snapshot in
/// every `.graphChanged` (which is how the CLI reads it — no second read path), and
/// the global graph at `graphcode://global` becoming a cross-project board without
/// a line of extra code. Empty for anyone who never touches the board; graphs saved
/// before the field existed decode with it empty.
/// writer (the daemon), atomic persistence beside the graph file, and the global
/// graph at `graphcode://global` becoming a cross-project room without a line of
/// extra code. Empty for anyone who never touches the room; graphs saved before the
/// field existed decode with it empty.
///
/// **Not on the wire.** A `.graphChanged` snapshot carries `mailroomDigest` in this
/// field's place (`wireSnapshot()`): the posts were three quarters of every
/// broadcast frame on a busy graph (issue #288). A client reads them through
/// `DaemonCommand.mailbox`, bounded and on request, and one holding a copy fills this
/// field back in itself.
public var mailroom: [MailroomPost] = []
/// What a snapshot says about the room instead of shipping it — see `mailroom`.
/// Set only on the copy a daemon sends (`wireSnapshot()`); `nil` on the graph the
/// daemon owns and persists.
public var mailroomDigest: MailroomDigest?

public var project: ProjectRef {
get { scope.projectRef }
Expand All @@ -38,6 +47,21 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable {

public var isGlobal: Bool { scope.isGlobal }

/// The room as a snapshot describes it, from whichever side of the socket this
/// graph is on: the digest a daemon stamped, or — for a graph that still carries its
/// posts, as the daemon's own does and a pre-digest daemon's snapshots did — one
/// computed from them.
public var boardDigest: MailroomDigest { mailroomDigest ?? MailroomDigest(of: mailroom) }

/// This graph as a `.graphChanged` frame carries it: the posts stripped and their
/// digest stamped in their place. Everything else is the graph exactly as it is.
public func wireSnapshot() -> LoopGraph {
var copy = self
copy.mailroomDigest = MailroomDigest(of: mailroom)
copy.mailroom = []
return copy
}

public init(
id: UUID = UUID(),
scope: LoopGraphScope,
Expand Down Expand Up @@ -254,7 +278,7 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable {
// MARK: - Coding

private enum CodingKeys: String, CodingKey {
case id, nodes, edges, mailroom
case id, nodes, edges, mailroom, mailroomDigest
/// 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
Expand All @@ -272,6 +296,7 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable {
mailroom =
try container.decodeIfPresent([MailroomPost].self, forKey: .mailroom)
?? decoder.legacyMailroomValue([MailroomPost].self, "artifactory") ?? []
mailroomDigest = try container.decodeIfPresent(MailroomDigest.self, forKey: .mailroomDigest)
}

public func encode(to encoder: Encoder) throws {
Expand All @@ -283,5 +308,6 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable {
// 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 !mailroom.isEmpty { try container.encode(mailroom, forKey: .mailroom) }
if let mailroomDigest { try container.encode(mailroomDigest, forKey: .mailroomDigest) }
}
}
14 changes: 12 additions & 2 deletions GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,7 @@ public actor GraphStore {
// snapshot it was joining for.
OutboundChannels.open(fileDescriptor)
connections[id] = fileDescriptor
send(.graphChanged(graph), to: id)
send(.graphChanged(graph.wireSnapshot()), to: id)
}

public func removeConnection(_ id: UUID) {
Expand Down Expand Up @@ -1690,6 +1690,15 @@ public actor GraphStore {
graph.mailroom = Mailroom.pruned(graph.mailroom + [post])
}

/// The room as a client asked for it — the read half of every mail verb, and the
/// only way posts leave the daemon now that `.graphChanged` carries their digest
/// instead (issue #288). Pure: nothing moves, nothing is persisted, nobody else
/// hears about it. Not gated on the room being on — reading was never gated, and a
/// room switched off still shows what was said while it was on.
public func mailbox(_ query: MailboxQuery) -> Mailbox {
Mailroom.serve(query, from: graph.mailroom) { graph.nodes[id: $0]?.lastMailroomRead }
}

/// Advances the reading loop's cursor to the newest post — the write half of
/// `graphcode mail inbox`. Deliberately no memory record: sync is reading,
/// not learning, and a log line per read would turn the log into a metronome.
Expand Down Expand Up @@ -3204,8 +3213,9 @@ public actor GraphStore {
/// other caller wants `broadcast` — a graph change that isn't saved is a graph change
/// lost at the next daemon restart.
private func notifyClients() {
let snapshot = graph.wireSnapshot()
for id in connections.keys {
send(.graphChanged(graph), to: id)
send(.graphChanged(snapshot), to: id)
}
}

Expand Down
30 changes: 23 additions & 7 deletions GraphcodeKit/Sources/IPC/DaemonProtocol.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import MailroomKit

/// What the app (or, eventually, the `graphcode` CLI) can ask `graphcoded` to do. See
/// docs/03-architecture.md#background-daemons and
Expand Down Expand Up @@ -38,6 +39,13 @@ public enum DaemonCommand: Codable, Sendable, Equatable {
/// `forgetProject` precisely because it is.
case deleteProjectGraph(path: String)
case graphCommand(projectPath: String, command: GraphCommand)
/// Read the project's Mailroom — the whole room, one loop's unread slice of it, or
/// one post — answered on this connection alone with a `.mailbox`. This is the read
/// path the room has instead of riding every `.graphChanged`: a snapshot carries only
/// `LoopGraph.mailroomDigest`, and whoever wants posts asks for exactly the posts it
/// wants (issue #288). Requires the project to be resident in the daemon — opened by
/// some connection — the same rule as `.graphCommand`.
case mailbox(projectPath: String, query: MailboxQuery)
}

/// Mutations against exactly one project's graph — this is what `GraphStore.handle`
Expand Down Expand Up @@ -138,12 +146,12 @@ public indirect enum GraphCommand: Codable, Sendable, Equatable {
/// (`mailroomEnabled` in `~/.graphcode/settings.json`) — a silent no-op would read,
/// to the loop that sent it, as a post nobody answered.
case mailroomPost(text: String, topic: String?, from: UUID?)
/// Mark every post on the Mailroom as read for the calling loop — `graphcode
/// mail inbox`, the cursor half of reading. The CLI reads the board out of the
/// graph snapshot it already gets from `openProject`; this is the write that makes
/// "unread" mean something the *next* sync can subtract from. Requires a loop
/// identity: a human reading the board needs no cursor, since nothing downstream
/// tracks what they have seen.
/// Mark every post on the Mailroom as read for the calling loop — `graphcode mail
/// inbox`, the cursor half of reading. The posts themselves come back on a
/// `DaemonCommand.mailbox` sent first; this is the write that makes "unread" mean
/// something the *next* inbox can subtract from. Requires a loop identity: a human
/// reading the room needs no cursor, since nothing downstream tracks what they have
/// seen.
case mailroomInbox(from: UUID?)
/// Subscribe (`on: true`) or unsubscribe (`on: false`) the calling loop to Mailroom
/// posts — `graphcode mail watch`. A watched post is delivered the way a
Expand Down Expand Up @@ -192,7 +200,11 @@ public indirect enum GraphCommand: Codable, Sendable, Equatable {
/// other connected client subscribed to the same project, so two open windows never
/// disagree about that project's graph state. `graphChanged` always carries the *full*
/// graph rather than a diff: simplest possible thing that keeps every client in sync,
/// and small enough at this scale that a diff protocol isn't worth the complexity yet.
/// and small enough at this scale that a diff protocol isn't worth the complexity yet —
/// with one exception. The Mailroom's posts are left out (`LoopGraph.wireSnapshot()`)
/// and their digest sent instead: they were three quarters of every frame on a busy
/// graph, re-sent to every client on every change, and a client reads them through
/// `DaemonCommand.mailbox` when it actually wants them.
/// The graph's own `project` field is what tells a client which project a
/// `graphChanged` event belongs to — there's no separate "project opened" event,
/// because joining a project already gets one of these as an immediate snapshot (see
Expand All @@ -201,4 +213,8 @@ public enum DaemonEvent: Codable, Sendable, Equatable {
case recentProjectsListed([ProjectRef])
case graphChanged(LoopGraph)
case errorOccurred(String)
/// The answer to a `DaemonCommand.mailbox`, sent only to the connection that asked.
/// `projectPath` is the canonical spelling the daemon routed the query to, which is
/// the id an app keys its projects by.
case mailbox(projectPath: String, mailbox: Mailbox)
}
Loading
Loading