Skip to content
Open
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
106 changes: 92 additions & 14 deletions ArtifactoryKit/Sources/Artifactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,26 @@ public struct ArtifactoryPost: Codable, Equatable, Identifiable, Sendable {
/// own quota, where everything on the board was a note.
public let kind: Kind

/// Whether a loop *wrote* this, or the board is only noting that a delivery happened.
///
/// A separate axis from `kind` on purpose, and the two were conflated once. `kind`
/// answers "whose budget prunes this" — mirrored traffic must have its own quota or a
/// talkative graph evicts every note. It was also read as "is there anything in here
/// to read", which it never was: `node send` mirrors as a `.record` because that is
/// the budget it belongs to, and the whole text a loop typed rode along inside it.
/// Every surface then folded it away, so two loops correcting each other's diagnosis
/// held the conversation somewhere no supervisor ever looked (#273).
///
/// A hand-off nudge carrying no payload really is a receipt. The text of a `node send`
/// is not, and this is the bit that says so.
///
/// Absent from boards saved before the split, where a record was only ever a receipt —
/// which is also why it rides beside `kind` rather than becoming a third case of it:
/// an older build decoding a newer board must not meet a raw value it has never heard
/// of, and losing a project's whole graph to a rolled-back beta is a steep price for a
/// tidier enum.
public let wasWritten: Bool

/// Cached formatter for CLI rendering — one `DateFormatter` per process rather than
/// per post, and a fixed `dateFormat` with a pinned locale rather than
/// `Date.formatted` or named `DateFormatter.Style` cases, neither of which is
Expand All @@ -66,9 +86,11 @@ public struct ArtifactoryPost: Codable, Equatable, Identifiable, Sendable {
return formatter
}()

/// `wasWritten` defaults to what the kind implies: a note is always somebody speaking,
/// and a record is a receipt unless its caller says otherwise.
public init(
id: Int, at: Date, authorID: UUID?, author: String, topic: String?, body: String,
kind: Kind = .note
kind: Kind = .note, wasWritten: Bool? = nil
) {
self.id = id
self.at = at
Expand All @@ -77,10 +99,11 @@ public struct ArtifactoryPost: Codable, Equatable, Identifiable, Sendable {
self.topic = topic
self.body = body
self.kind = kind
self.wasWritten = wasWritten ?? (kind == .note)
}

private enum CodingKeys: String, CodingKey {
case id, at, authorID, author, topic, body, kind
case id, at, authorID, author, topic, body, kind, wasWritten
}

/// Hand-written for the reason `LoopNode`'s is: a board saved before `kind` existed
Expand All @@ -93,8 +116,12 @@ public struct ArtifactoryPost: Codable, Equatable, Identifiable, Sendable {
authorID = try container.decodeIfPresent(UUID.self, forKey: .authorID)
author = try container.decode(String.self, forKey: .author)
topic = try container.decodeIfPresent(String.self, forKey: .topic)
body = try container.decode(String.self, forKey: .body)
kind = try container.decodeIfPresent(Kind.self, forKey: .kind) ?? .note
let body = try container.decode(String.self, forKey: .body)
self.body = body
wasWritten =
try container.decodeIfPresent(Bool.self, forKey: .wasWritten)
?? (kind == .note || !Artifactory.readsAsADeliveryReceipt(body))
}

/// The same post with its author's handle gone — what deleting a loop leaves behind.
Expand All @@ -106,7 +133,7 @@ public struct ArtifactoryPost: Codable, Equatable, Identifiable, Sendable {
public func withAuthorDeleted() -> ArtifactoryPost {
ArtifactoryPost(
id: id, at: at, authorID: nil, author: "\(author) (deleted)", topic: topic,
body: body, kind: kind)
body: body, kind: kind, wasWritten: wasWritten)
}

/// The bound that keeps "check the board" cheap. A note that cannot fit in a
Expand Down Expand Up @@ -135,11 +162,57 @@ public enum Artifactory {
/// loops that cannot read that log.
public static let maxNotes = 200

/// How many mirrored records a board keeps, pruned entirely separately from the
/// notes. Smaller because a record is a receipt for something already delivered:
/// enough that a loop joining mid-flight can see what was recently said, not so
/// many that the graph's chatter becomes the board.
public static let maxRecords = 50
/// How many mirrored *messages* a board keeps — a `node send`, or an edge that fired
/// carrying a payload — pruned entirely separately from the notes. Smaller because a
/// message was already delivered elsewhere: enough that a loop joining mid-flight can
/// see what was recently said, not so many that the graph's chatter becomes the board.
public static let maxMessages = 50

/// How many delivery receipts a board keeps, on their own budget beneath the messages.
///
/// Sharing one quota was survivable while both halves were hidden. Once the written
/// half takes rows, it is not: a `.none`-transform edge on a cycle mirrors a fresh
/// "@X: Y finished." every pass (`reenterCycle` resets `fireCount`), so fifty passes
/// would evict the conversation this section exists to show and leave "50 delivery
/// receipts" in its place. Bookkeeping cannot be allowed to price out the thing it is
/// bookkeeping for, which is the same argument that split notes from records to begin
/// with, one level down.
///
/// Small on purpose: a receipt says only that an edge fired, and the rollup shows
/// eight before it offers the rest.
public static let maxReceipts = 20

/// What the mirror appends to a source loop's title when an edge fires carrying
/// nothing — the whole of such a post's body, and the reason it is a receipt.
///
/// Here rather than at the two places in `GraphcodeKit` that write it, because
/// `readsAsADeliveryReceipt` has to recognise the same words. Duplicated across the
/// module boundary they would drift silently: reword either one and every legacy
/// receipt reclassifies as written, with no compile error and no failing test.
public static let firedWithNothingToSay = " finished."

/// The same, for a hand-off that carried no payload. A hand-off *with* one appends it
/// after this, which is why the test that uses it is a suffix test.
public static let handedOffWithNothingToSay = " finished and handed its work off to you."

/// Whether a record saved before `wasWritten` existed is one of the two lines the
/// daemon generates itself, rather than something a loop said.
///
/// Boards written before the split carry no flag, and a default of "receipt" would
/// leave every conversation already on them exactly as buried as #273 found it — a
/// fix that only helps graphs created after it shipped. There is no other signal left
/// on those posts, so this reads the two shapes the mirror produces when an edge fires
/// with nothing in it (`MessageBus.messageText`'s `.none`, and a hand-off with no
/// payload). Everything else on those topics is a `node send` or a payload, which is
/// somebody talking.
///
/// Deliberately narrow. A written message that happens to end "… finished." is read as
/// a receipt and stays folded, which is where it already was; the opposite mistake
/// would put the daemon's own bookkeeping in front of a reader as though a loop had
/// said it. Only posts decoded without the flag are ever asked.
public static func readsAsADeliveryReceipt(_ body: String) -> Bool {
body.hasSuffix(firedWithNothingToSay) || body.hasSuffix(handedOffWithNothingToSay)
}

/// The id the next post gets. Maximum-plus-one, never count-plus-one: pruning
/// removes the oldest posts, and reusing their ids would make unread cursors
Expand Down Expand Up @@ -174,14 +247,19 @@ public enum Artifactory {
return posts.filter { $0.id > lastRead }
}

/// A board pruned to both budgets, oldest of each kind gone first and the survivors
/// back in one sequence. Applied by the store on every write so no caller can forget.
/// A board pruned to all three budgets, oldest of each pool gone first and the
/// survivors back in one sequence. Applied by the store on every write so no caller
/// can forget.
public static func pruned(_ posts: [ArtifactoryPost]) -> [ArtifactoryPost] {
let notes = posts.filter { $0.kind == .note }
let records = posts.filter { $0.kind == .record }
guard notes.count > maxNotes || records.count > maxRecords else { return posts }
let messages = posts.filter { $0.kind == .record && $0.wasWritten }
let receipts = posts.filter { $0.kind == .record && !$0.wasWritten }
guard
notes.count > maxNotes || messages.count > maxMessages || receipts.count > maxReceipts
else { return posts }
let kept = Set(
(notes.suffix(maxNotes) + records.suffix(maxRecords)).map(\.id))
(notes.suffix(maxNotes) + messages.suffix(maxMessages) + receipts.suffix(maxReceipts))
.map(\.id))
return posts.filter { kept.contains($0.id) }
}
}
2 changes: 1 addition & 1 deletion GraphcodeKit/Sources/Domain/LoopGraph.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable {
public var edges: IdentifiedArrayOf<LoopEdge>
/// The project's Artifactory — every post any loop has dropped onto the shared board,
/// oldest first, notes and mirrored records each capped on their own budget
/// (`Artifactory.maxNotes`, `Artifactory.maxRecords`). Kept on the graph rather than in a
/// (`Artifactory.maxNotes`, `maxMessages`, `maxReceipts`). 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
Expand Down
30 changes: 22 additions & 8 deletions GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1660,15 +1660,21 @@ public actor GraphStore {
}

/// Writes a shared communication onto the artifactory — the durable record the
/// board keeps of everything the graph's loops said to each other. Record-only by
/// board keeps of everything the graph's loops said to each other. Silent by
/// design: the communication already reached its target (or is waiting in staged
/// memory to), so mirroring must not ring the watchers, or a busy graph would have
/// every direct message waking every listener on top of its real delivery.
/// Gated like every board write; body carries the target so a reader can tell a
/// note to the room from a note to a peer. Written as `.record`, which is what keeps
/// note to the room from a note to a peer. Always a `.record`, which is what keeps
/// a talkative graph inside its own budget instead of evicting the notes.
///
/// `wasWritten` is the other half, and the caller is the only one who knows it: the
/// text of a `node send` is a loop talking, while "Author finished." is the board
/// noticing that an edge fired. Both belong in the record's budget; only the first
/// belongs in front of a reader.
private func recordArtifactoryCommunication(
from senderID: UUID?, to target: LoopNode, text: String, topic: String
from senderID: UUID?, to target: LoopNode, text: String, topic: String,
wasWritten: Bool
) {
guard onArtifactoryEnabled?() == true else { return }
let sender = senderID.flatMap { graph.nodes[id: $0]?.title } ?? "a human"
Expand All @@ -1681,7 +1687,7 @@ public actor GraphStore {
}
let post = ArtifactoryPost(
id: Artifactory.nextID(after: graph.artifactory), at: Date(), authorID: senderID,
author: sender, topic: topic, body: body, kind: .record)
author: sender, topic: topic, body: body, kind: .record, wasWritten: wasWritten)
graph.artifactory = Artifactory.pruned(graph.artifactory + [post])
}

Expand Down Expand Up @@ -2359,7 +2365,12 @@ public actor GraphStore {
if record.hasPrefix("\(source.title): ") {
record.removeFirst("\(source.title): ".count)
}
recordArtifactoryCommunication(from: source.id, to: target, text: record, topic: "direct")
recordArtifactoryCommunication(
from: source.id, to: target, text: record, topic: "direct",
// An edge with no transform says only that the upstream finished — the board's
// own observation, not the loop's. A template or a captured script is content
// somebody put there.
wasWritten: edge.payloadTransform != .none)
graph.edges[id: edgeID]?.fireCount += 1
}
}
Expand Down Expand Up @@ -2454,15 +2465,18 @@ public actor GraphStore {
"Cycle re-entry \(edge.fireCount)\(bound) — the stop condition is not yet met. "
+ "Continue toward your goal.")
} else {
parts.append("\(source.title) finished and handed its work off to you.")
parts.append("\(source.title)\(Artifactory.handedOffWithNothingToSay)")
// The handoff itself is shared communication and gets its record — with its
// payload, which is the part a later reader actually needs. Cycle re-entries
// are the daemon's own metronome, not a loop saying anything, so they stay
// out of the record the same way heartbeat ticks stay out of memory logs.
var record = parts.joined(separator: " ")
if let payload { record += " " + payload }
recordArtifactoryCommunication(
from: source.id, to: target, text: record, topic: "handoff")
from: source.id, to: target, text: record, topic: "handoff",
// The payload is the part a later reader needs; without one the line is the
// boilerplate above and nothing else, which is a receipt.
wasWritten: payload != nil)
}
if let payload {
parts.append(payload)
Expand Down Expand Up @@ -2541,7 +2555,7 @@ public actor GraphStore {
// that already exists, and recording it would have the board record itself.
if mirror {
recordArtifactoryCommunication(
from: senderID, to: target, text: trimmed, topic: "direct")
from: senderID, to: target, text: trimmed, topic: "direct", wasWritten: true)
}
// Attributed when the sender is a loop in this graph, the way a message edge names
// its source — the target should know who's talking without guessing.
Expand Down
5 changes: 4 additions & 1 deletion GraphcodeKit/Sources/Sessions/MessageBus.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ArtifactoryKit
import Foundation

/// Delivery for `.message`-kind edges — docs/02-graph-of-loops.md#inter-loop-messaging-in-practice
Expand Down Expand Up @@ -130,7 +131,9 @@ public enum MessageBus {
switch edge.payloadTransform {
case .none:
// Still worth sending: the fact that the upstream finished is itself the message.
return "[graphcode] \(source.title) finished."
// The suffix is `ArtifactoryKit`'s so the board can recognise its own bookkeeping
// on a post saved before it was flagged as such.
return "[graphcode] \(source.title)\(Artifactory.firedWithNothingToSay)"
case .template(let text):
return text.isEmpty ? nil : "[graphcode] \(source.title): \(text)"
case .script(let command):
Expand Down
Loading
Loading