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
53 changes: 41 additions & 12 deletions GraphcodeKit/Sources/CLI/GraphcodeCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,14 @@ public enum GraphcodeCommand: Equatable, Sendable {
graphcode mail post <project-path> [--topic <t>] <notice…>
post a notice to the whole graph, for whoever comes next
graphcode mail inbox <project-path> [--headlines] [--full] [--mark] [--json]
read your unread mail and mark the room read. A large
backlog prints as headlines on its own and says so —
--full insists on every body, --headlines insists on
triage lines (deep-read either with `read`). --mark
advances the cursor without printing the backlog, --json is
the machine-readable shape. Combined, the output wins in the
order --json > --mark > --headlines > --full; the cursor
advances whichever flags you pass
read your unread mail; what prints is what is marked read.
A large backlog prints as headlines on its own and says
so — --full insists on every body, --headlines insists on
triage lines (deep-read either with `read`) — and comes a
page at a time: run it again for the next page. --mark
marks everything read without printing it, --json is the
machine-readable shape. Combined, the output wins in the
order --json > --mark > --headlines > --full
graphcode mail read <project-path> <post-id>
one post in full — the deep-read half of --headlines
graphcode mail list <project-path> [--search <text>] [--json]
Expand Down Expand Up @@ -866,9 +866,14 @@ extension GraphcodeCommand {
if let search, !search.isEmpty {
return unread ? "no unread posts match '\(search)'" : "no posts match '\(search)'"
}
return unread
? "no unread posts"
: "the room is empty — post one: graphcode mail post <project-path> <notice…>"
guard unread else {
return "the room is empty — post one: graphcode mail post <project-path> <notice…>"
}
return mailbox.prunedUnread > 0
? "no unread posts — but \(mailbox.prunedUnread) landed since your last inbox and "
+ "were pruned before you read them; the room keeps \(Mailroom.maxNotices) notices "
+ "and \(Mailroom.maxLetters) letters, read it more often"
: "no unread posts"
}
let triaged = mailbox.bodiesTrimmed && !headlines
let label = unread ? "mailroom, unread" : "mailroom"
Expand All @@ -883,6 +888,23 @@ extension GraphcodeCommand {
lines.append(
headlines || mailbox.bodiesTrimmed ? " \(renderHeadline(post))" : " \(render(post))")
}
if unread, mailbox.prunedUnread > 0 {
// Said before the posts, in words, or the loop believes it is caught up: mail
// that landed after its cursor and was pruned before it asked is gone for good.
lines.append(
" \(mailbox.prunedUnread) post\(mailbox.prunedUnread == 1 ? "" : "s") landed since your "
+ "last inbox and \(mailbox.prunedUnread == 1 ? "was" : "were") pruned before you read "
+ "\(mailbox.prunedUnread == 1 ? "it" : "them") — the room keeps "
+ "\(Mailroom.maxNotices) notices and \(Mailroom.maxLetters) letters; read it more often")
}
if mailbox.remaining > 0 {
// A page, not the whole backlog: the cursor stopped at the last post above, so
// the same command again is the next page — said in words, or a loop would
// take one page for the lot.
lines.append(
" \(mailbox.remaining) more unread past #\(mailbox.highestDeliveredID ?? 0) — "
+ "run the same command again for the next page")
}
return lines.joined(separator: "\n")
}

Expand All @@ -894,8 +916,15 @@ extension GraphcodeCommand {
struct Board: Encodable {
var posts: [MailroomPost]
var lastRead: Int?
/// Present only when the answer is a page, so the shape scripts already parse
/// is untouched until there is something to say.
var remaining: Int?
var prunedUnread: Int?
}
let board = Board(posts: mailbox.posts, lastRead: mailbox.lastRead)
let board = Board(
posts: mailbox.posts, lastRead: mailbox.lastRead,
remaining: mailbox.remaining > 0 ? mailbox.remaining : nil,
prunedUnread: mailbox.prunedUnread > 0 ? mailbox.prunedUnread : nil)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
encoder.dateEncodingStrategy = .iso8601
Expand Down
97 changes: 69 additions & 28 deletions GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,10 @@ public actor GraphStore {

// MARK: - Commands

public func handle(_ command: GraphCommand) async {
/// `from` is the connection the command arrived on, when the registry knows it — what
/// lets a refusal meant for one client go to that client alone. Tests drive the
/// store without one and hear refusals through `onAnnounceError`.
public func handle(_ command: GraphCommand, from connectionID: UUID? = nil) async {
// A loop inside a composite addresses itself by its own id — its briefing tells it
// to `node memo <project> <its-own-id>`, and ids are unique across the whole tree,
// so a caller has no reason to know how deep its target sits (the same rule
Expand Down Expand Up @@ -648,8 +651,8 @@ public actor GraphStore {
case .mailroomPost(let text, let topic, let from):
await mailroomPost(text: text, topic: topic, from: from)

case .mailroomInbox(let from):
mailroomInbox(from: from)
case .mailroomInbox:
refuseLegacyInbox(to: connectionID)

case .mailroomWatch(let on, let topic, let from):
mailroomWatch(on: on, topic: topic, from: from)
Expand Down Expand Up @@ -1690,39 +1693,77 @@ 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 }
/// Why a mailbox request was refused — the daemon's wording, for the asking
/// connection alone rather than every client (`announceError` broadcasts).
public struct MailboxRefusal: Error, Equatable {
public let message: String
}

/// 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.
private func mailroomInbox(from readerID: UUID?) {
/// 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). Reading is not gated on the room being on — it never was,
/// and a room switched off still shows what was said while it was on.
///
/// With `advanceCursor`, also the acknowledgement: the reader's cursor moves to the
/// highest post in the answer, in the same actor turn the answer was drawn — so no
/// post can land between the read and the mark and be marked read unseen, and a
/// page that stops short (`Mailroom.inboxPageSize`) leaves the rest unread for the
/// next request. Persisted, never broadcast: a cursor is the reader's alone, and the
/// next snapshot anything else causes carries it anyway.
public func mailbox(_ query: MailboxQuery) throws -> Mailbox {
let mailbox = Mailroom.serve(query, from: graph.mailroom) {
graph.nodes[id: $0]?.lastMailroomRead
}
guard query.advanceCursor == true, case .unread(let readerID) = query.selection else {
return mailbox
}
// A searched answer skips unread posts, and a cursor only moves through mail that
// was handed over — `highestDeliveredID` already stops at the first miss, but the
// pair is refused outright so no caller can lean on remembering that.
guard query.search?.isEmpty ?? true else {
throw MailboxRefusal(
message: "a searched inbox cannot move the cursor — search with `mail list`, or "
+ "read the inbox unsearched")
}
guard mailroomIsOn() else {
announceError(
"the Mailroom is off — enable Mailroom in Settings "
throw MailboxRefusal(
message: "the Mailroom is off — enable Mailroom in Settings "
+ "(mailroomEnabled in ~/.graphcode/settings.json)")
return
}
guard let readerID, graph.nodes[id: readerID] != nil else {
announceError(
"mail inbox needs a loop identity — run it from a loop's session "
guard graph.nodes[id: readerID] != nil else {
throw MailboxRefusal(
message: "mail inbox needs a loop identity — run it from a loop's session "
+ "($ZMX_SESSION); a human reading the board needs no cursor")
return
}
// Never moves backward: ids only grow (`Mailroom.nextID` is max-plus-one), so
// the max below only guards a board emptied by something other than pruning.
let latest = graph.mailroom.last?.id ?? 0
// Read into a local first: reading and writing the cursor through the same
// `IdentifiedArray` subscript in one expression is an overlapping access the
// runtime treats as fatal exclusivity.
// Never moves backward, and never past what was handed over.
let current = graph.nodes[id: readerID]?.lastMailroomRead ?? 0
graph.nodes[id: readerID]?.lastMailroomRead = max(latest, current)
let delivered = mailbox.highestDeliveredID ?? current
guard delivered > current else { return mailbox }
graph.nodes[id: readerID]?.lastMailroomRead = delivered
onGraphChanged?(graph)
return mailbox
}

/// What `GraphCommand.mailroomInbox` does now: nothing to the cursor, and says why.
///
/// This was "advance to the newest post" — the acknowledgement half of a `mail inbox`
/// that read the posts off its snapshot. Snapshots no longer carry posts, so the one
/// client still sending this is a CLI older than the daemon, which has just printed
/// "no unread posts" off an empty snapshot and would now have its cursor moved past
/// mail it never saw — permanently, upgrade or not. A cursor moves only through mail
/// that was handed over, so the old command is refused loudly and the new one
/// (`MailboxQuery.advanceCursor`) is the only thing that moves it.
private func refuseLegacyInbox(to connectionID: UUID?) {
let message =
"this graphcode CLI predates the daemon's mailbox — nothing was marked read. "
+ "Upgrade graphcode (the app installs it beside graphcoded) and run the "
+ "inbox again"
// To the asker alone, never `announceError`: that writes to every connected
// client, and one stale CLI's problem is nobody else's error to read.
if let connectionID, connections[connectionID] != nil {
send(.errorOccurred(message), to: connectionID)
}
onAnnounceError?(message)
}

/// Subscribes or unsubscribes the calling loop. Recorded to the loop's memory so a
Expand Down
13 changes: 7 additions & 6 deletions GraphcodeKit/Sources/IPC/DaemonProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,13 @@ 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 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.
/// **Refused by a daemon from this version on.** This was the acknowledgement half of
/// `mail inbox` — "advance my cursor to the newest post" — sent after a CLI had read
/// the posts off its snapshot. Snapshots carry no posts now, and the cursor moves only
/// through mail actually handed over (`MailboxQuery.advanceCursor`); a client still
/// sending this is older than the daemon and would otherwise have its cursor moved
/// past mail it never saw. Kept on the wire so that client gets an answer that says
/// so instead of a hang-up.
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
14 changes: 10 additions & 4 deletions GraphcodeKit/Sources/ProjectRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ public actor ProjectRegistry {
send(.errorOccurred("\(path) isn't open — open it first."), to: fileDescriptor)
return
}
await store.handle(inner)
await store.handle(inner, from: connectionID)
case .refused(let reason):
send(.errorOccurred(reason), to: fileDescriptor)
}
Expand All @@ -367,9 +367,15 @@ public actor ProjectRegistry {
send(.errorOccurred("\(path) isn't open — open it first."), to: fileDescriptor)
return
}
send(
.mailbox(projectPath: canonicalPath, mailbox: await store.mailbox(query)),
to: fileDescriptor)
do {
send(
.mailbox(projectPath: canonicalPath, mailbox: try await store.mailbox(query)),
to: fileDescriptor)
} catch let refusal as GraphStore.MailboxRefusal {
send(.errorOccurred(refusal.message), to: fileDescriptor)
} catch {
send(.errorOccurred("\(error)"), to: fileDescriptor)
}
case .refused(let reason):
send(.errorOccurred(reason), to: fileDescriptor)
}
Expand Down
Loading
Loading