Merge branch 'dev' into canary
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
import NucleicProtocol
|
||||||
|
|
||||||
|
/// The on-device snapshot of the phone's mesh-cast ledger (`CastLedger`) — the activity feed,
|
||||||
|
/// mesh messages, and runner-presence cards it has received, plus (implicitly) the cursors a
|
||||||
|
/// reconnect subscribes with. Like `SessionCache`, a *projection cache*, never canonical: a
|
||||||
|
/// live host always supersedes it; it exists so a relaunch renders the feed instantly and
|
||||||
|
/// resubscribes O(gap) instead of re-pulling every channel from zero.
|
||||||
|
///
|
||||||
|
/// One JSON file under Application Support (`NucleicCastCache/casts.json`) — the ledger's
|
||||||
|
/// per-channel retention already bounds it. Reads/writes hop onto a utility `Task.detached`.
|
||||||
|
enum CastCache {
|
||||||
|
private static var root: URL? {
|
||||||
|
guard let base = try? FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
else { return nil }
|
||||||
|
return base.appendingPathComponent("NucleicCastCache", isDirectory: true)
|
||||||
|
}
|
||||||
|
private static var castsURL: URL? { root?.appendingPathComponent("casts.json") }
|
||||||
|
|
||||||
|
/// Load the persisted ledger — called once at launch, before any host connects. The ledger's
|
||||||
|
/// init re-applies retention, so a policy change between builds re-caps on load.
|
||||||
|
static func loadLedger() -> CastLedger {
|
||||||
|
guard let url = castsURL, let data = try? Data(contentsOf: url),
|
||||||
|
let casts = try? JSONDecoder().decode([WireCast].self, from: data)
|
||||||
|
else { return CastLedger() }
|
||||||
|
return CastLedger(casts: casts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the ledger's snapshot. Off the main actor; callers debounce bursts.
|
||||||
|
static func save(_ ledger: CastLedger) async {
|
||||||
|
let casts = ledger.allCasts
|
||||||
|
await Task.detached(priority: .utility) {
|
||||||
|
guard let root, let url = castsURL else { return }
|
||||||
|
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
if let data = try? JSONEncoder().encode(casts) {
|
||||||
|
try? data.write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the cache — on full unpair (no Mac left whose casts this device should hold).
|
||||||
|
static func clear() {
|
||||||
|
guard let root else { return }
|
||||||
|
try? FileManager.default.removeItem(at: root)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,6 +116,17 @@ final class HostConnection {
|
|||||||
/// The outcome of a `createProject` this phone sent (CLOUD_RUNTIME §4.3), correlated by
|
/// The outcome of a `createProject` this phone sent (CLOUD_RUNTIME §4.3), correlated by
|
||||||
/// requestID — settle the Add Project sheet (the project row rides the dashboard push).
|
/// requestID — settle the Add Project sheet (the project row rides the dashboard push).
|
||||||
var projectCreated: (WireProjectCreated) -> Void = { _ in }
|
var projectCreated: (WireProjectCreated) -> Void = { _ in }
|
||||||
|
/// The cursors this connection's `castSubscribe` carries (mesh casting) — the merged
|
||||||
|
/// ledger's tips across EVERY origin, so any connected Mac tops up any origin's gap.
|
||||||
|
var castCursors: () -> [CastCursor] = { [] }
|
||||||
|
/// A live batch of mesh casts from this host — RemoteStore applies it to the merged
|
||||||
|
/// ledger (identity dedup absorbs the same cast arriving via N Macs).
|
||||||
|
var castsReceived: ([WireCast]) -> Void = { _ in }
|
||||||
|
/// One catch-up batch for one (origin, channel) of the cast feed.
|
||||||
|
var castCatchUp: (CastCatchUp) -> Void = { _ in }
|
||||||
|
/// A mesh-dispatch ack from this host (mesh dispatch) — RemoteStore correlates it by
|
||||||
|
/// `requestID` to resume the waiting `dispatchChatToMesh`.
|
||||||
|
var chatStarted: (WireChatStarted) -> Void = { _ in }
|
||||||
}
|
}
|
||||||
private let callbacks: Callbacks
|
private let callbacks: Callbacks
|
||||||
|
|
||||||
@@ -396,13 +407,15 @@ final class HostConnection {
|
|||||||
releaseChannel: BuildInfo.current.channel.releaseChannel,
|
releaseChannel: BuildInfo.current.channel.releaseChannel,
|
||||||
// Mesh "join": advertise roster gossip so a host pushes its group view — the phone
|
// Mesh "join": advertise roster gossip so a host pushes its group view — the phone
|
||||||
// then auto-learns and connects to every Mac in the mesh, not just the one it scanned.
|
// then auto-learns and connects to every Mac in the mesh, not just the one it scanned.
|
||||||
// Also offer this phone as a low-tier AFM executor (ANTIMATTER_RUNNER §5) when the
|
// Also offer this phone as a low-tier AFM executor (CARBON_RUNNER §5) when the
|
||||||
// OS has Foundation Models — a runner host's mesh queue may place background work
|
// OS has Foundation Models — a runner host's mesh queue may place background work
|
||||||
// here; the interactive tiers stay on desktop-class devices by the queue's rules.
|
// here; the interactive tiers stay on desktop-class devices by the queue's rules.
|
||||||
clientCaps: WireClientCapabilities(
|
clientCaps: WireClientCapabilities(
|
||||||
mesh: 1, canSyncRoster: true,
|
mesh: 1, canSyncRoster: true,
|
||||||
canProvideIntelligence: PhoneIntelligenceExecutor.isSupported,
|
canProvideIntelligence: PhoneIntelligenceExecutor.isSupported,
|
||||||
intelligenceProfile: PhoneIntelligenceExecutor.profile))
|
intelligenceProfile: PhoneIntelligenceExecutor.profile,
|
||||||
|
// Mesh casting: receive the activity feed / messages / runner presence.
|
||||||
|
canCast: true))
|
||||||
self.client = client
|
self.client = client
|
||||||
consume(client, pairingPayload: pairingPayload)
|
consume(client, pairingPayload: pairingPayload)
|
||||||
}
|
}
|
||||||
@@ -504,6 +517,12 @@ final class HostConnection {
|
|||||||
callbacks.didUpdate()
|
callbacks.didUpdate()
|
||||||
send(.listSessions)
|
send(.listSessions)
|
||||||
send(.listDashboard)
|
send(.listDashboard)
|
||||||
|
// Mesh casting: subscribe with the merged ledger's cursors (every origin we hold) —
|
||||||
|
// this host serves the gaps for its own casts AND any peer's casts it mirrors, so a
|
||||||
|
// phone converges even when a cast's origin is offline.
|
||||||
|
if welcome.capabilities.canCast {
|
||||||
|
send(.castSubscribe(CastSubscribe(cursors: callbacks.castCursors())))
|
||||||
|
}
|
||||||
// Warm-resubscribe from what we already have, so a reconnect on a long transcript replays
|
// Warm-resubscribe from what we already have, so a reconnect on a long transcript replays
|
||||||
// only the gap rather than snapping back to the host's 200-event tail.
|
// only the gap rather than snapping back to the host's 200-event tail.
|
||||||
if let id = openSessionID {
|
if let id = openSessionID {
|
||||||
@@ -654,18 +673,28 @@ final class HostConnection {
|
|||||||
// resolves (success or failure). Correlation by requestID happens in RemoteStore.
|
// resolves (success or failure). Correlation by requestID happens in RemoteStore.
|
||||||
callbacks.projectCreated(outcome)
|
callbacks.projectCreated(outcome)
|
||||||
case .intelligenceRequest(let request):
|
case .intelligenceRequest(let request):
|
||||||
// A runner host delegated one AFM job here (docs/ANTIMATTER_RUNNER.md §5) — it only
|
// A runner host delegated one AFM job here (docs/CARBON_RUNNER.md §5) — it only
|
||||||
// ever sends these after this app advertised `canProvideIntelligence`. Generate off
|
// ever sends these after this app advertised `canProvideIntelligence`. Generate off
|
||||||
// the event stream (a model call takes seconds) and answer with the same id.
|
// the event stream (a model call takes seconds) and answer with the same id.
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
let result = await PhoneIntelligenceExecutor.execute(request)
|
let result = await PhoneIntelligenceExecutor.execute(request)
|
||||||
self?.send(.intelligenceResult(result))
|
self?.send(.intelligenceResult(result))
|
||||||
}
|
}
|
||||||
|
case .chatStarted(let outcome):
|
||||||
|
// Mesh-dispatch ack — up to RemoteStore's requestID correlation map (the "Auto
|
||||||
|
// (Mesh)" composer destination awaits it).
|
||||||
|
callbacks.chatStarted(outcome)
|
||||||
|
case .casts(let batch):
|
||||||
|
// Live mesh casts — up to the merged ledger (identity dedup across N connections).
|
||||||
|
callbacks.castsReceived(batch.casts)
|
||||||
|
case .castCatchUp(let catchUp):
|
||||||
|
// One catch-up batch for one (origin, channel) — `reset` handling lives in the ledger.
|
||||||
|
callbacks.castCatchUp(catchUp)
|
||||||
case .credentialNeeded, .credentialUpdate,
|
case .credentialNeeded, .credentialUpdate,
|
||||||
// The owner's runner-pool credential (item 4) — inert until the phone grows a
|
// The owner's runner-pool credential (item 4) — inert until the phone grows a
|
||||||
// pool-management surface; Macs are the managers today.
|
// pool-management surface; Macs are the managers today.
|
||||||
.runnerPoolCredential:
|
.runnerPoolCredential:
|
||||||
// Antimatter runner credential verbs (docs/ANTIMATTER_RUNNER.md §6): a runner host
|
// Carbon runner credential verbs (docs/CARBON_RUNNER.md §6): a runner host
|
||||||
// asking for / mirroring sealed credentials. Inert here until the phone-side vault
|
// asking for / mirroring sealed credentials. Inert here until the phone-side vault
|
||||||
// lands — and a host only sends these to clients that advertised the matching
|
// lands — and a host only sends these to clients that advertised the matching
|
||||||
// `WireClientCapabilities`, which this app doesn't yet.
|
// `WireClientCapabilities`, which this app doesn't yet.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import NucleicProtocol
|
|||||||
import FoundationModels
|
import FoundationModels
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// The phone-side executor for delegated intelligence work (docs/ANTIMATTER_RUNNER.md §5,
|
/// The phone-side executor for delegated intelligence work (docs/CARBON_RUNNER.md §5,
|
||||||
/// item 6): a runner host pushes `HostMsg.intelligenceRequest` at this device — the mesh AFM
|
/// item 6): a runner host pushes `HostMsg.intelligenceRequest` at this device — the mesh AFM
|
||||||
/// queue only ever sends it the lower tiers (`completion`/`background`), which don't need
|
/// queue only ever sends it the lower tiers (`completion`/`background`), which don't need
|
||||||
/// desktop tok/s — and this renders the shared `IntelligenceDelegate` template on the local
|
/// desktop tok/s — and this renders the shared `IntelligenceDelegate` template on the local
|
||||||
|
|||||||
@@ -58,6 +58,150 @@ final class RemoteStore: ObservableObject {
|
|||||||
private var cachedSummaries: [WireSessionSummary] = SessionCache.loadSummaries()
|
private var cachedSummaries: [WireSessionSummary] = SessionCache.loadSummaries()
|
||||||
/// Debounced disk-write tasks for the offline cache (coalesce a burst of updates into one write).
|
/// Debounced disk-write tasks for the offline cache (coalesce a burst of updates into one write).
|
||||||
private var summaryPersistTask: Task<Void, Never>?
|
private var summaryPersistTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// The merged mesh-cast ledger (mesh casting) — the activity feed / messages / runner
|
||||||
|
/// presence from every origin, deduped on cast identity across all connected Macs. Seeded
|
||||||
|
/// from disk at launch (renders offline, and its tips are the reconnect cursors), persisted
|
||||||
|
/// debounced on change. Feed/dispatch surfaces read projections of this.
|
||||||
|
@Published private(set) var castLedger: CastLedger = CastCache.loadLedger()
|
||||||
|
private var castPersistTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// The merged fleet feed, oldest-first — the "activity" channel decoded across every origin.
|
||||||
|
/// Seeded from the ledger on appear (renders offline), appended by `applyCasts`.
|
||||||
|
@Published private(set) var activityFeed: [ActivityFeedItem] = []
|
||||||
|
/// When the user last viewed the feed — rows newer than this count as unread.
|
||||||
|
@Published private(set) var feedLastSeenAt: Date =
|
||||||
|
UserDefaults.standard.object(forKey: "nucleic.feed.lastSeenAt") as? Date ?? .distantPast
|
||||||
|
var feedUnreadCount: Int {
|
||||||
|
activityFeed.reduce(0) { $0 + ($1.event.at > feedLastSeenAt ? 1 : 0) }
|
||||||
|
}
|
||||||
|
func markFeedSeen() {
|
||||||
|
feedLastSeenAt = Date()
|
||||||
|
UserDefaults.standard.set(feedLastSeenAt, forKey: "nucleic.feed.lastSeenAt")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-flight mesh dispatches awaiting their `chatStarted` ack, keyed by requestID.
|
||||||
|
private var chatStartedWaiters: [String: CheckedContinuation<WireChatStarted?, Never>] = [:]
|
||||||
|
|
||||||
|
/// The latest runner-presence card per host (mesh dispatch), decoded from the merged cast
|
||||||
|
/// ledger's `runner.presence` state channel — the dispatcher's candidate pool.
|
||||||
|
private var runnerPresenceByHost: [String: RunnerPresence] {
|
||||||
|
castLedger.stateValues(channel: MeshCastChannel.runnerPresence)
|
||||||
|
.compactMapValues { RunnerPresence.decode($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the composer should offer "Auto (Mesh)" for the project the phone has selected:
|
||||||
|
/// some connected host advertising `canAcknowledgeDispatch` (other than the project's own
|
||||||
|
/// owner) holds a matching repo. The descriptor comes from the owning host's presence card.
|
||||||
|
func meshDispatchAvailable(forProject projectID: ProjectID) -> Bool {
|
||||||
|
meshDispatchCandidates(forProject: projectID) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the (descriptor, candidates) for a mesh dispatch, or nil when it isn't offerable.
|
||||||
|
private func meshDispatchCandidates(
|
||||||
|
forProject projectID: ProjectID
|
||||||
|
) -> (descriptor: ProjectDescriptor, candidates: [MeshDispatchScorer.Candidate])? {
|
||||||
|
let presences = runnerPresenceByHost
|
||||||
|
// The chosen project belongs to one host; find its descriptor from that host's card.
|
||||||
|
guard let owner = connection(owningProject: projectID)?.hostID,
|
||||||
|
let descriptor = presences[owner]?.projects.first(where: { $0.projectID == projectID })
|
||||||
|
else { return nil }
|
||||||
|
var candidates: [MeshDispatchScorer.Candidate] = []
|
||||||
|
for (hostID, presence) in presences {
|
||||||
|
guard let conn = connections[hostID] else { continue }
|
||||||
|
candidates.append(MeshDispatchScorer.Candidate(
|
||||||
|
hostID: hostID, presence: presence,
|
||||||
|
live: conn.connectivity.isLive,
|
||||||
|
canAcknowledgeDispatch: conn.capabilities.canAcknowledgeDispatch))
|
||||||
|
}
|
||||||
|
// Offer only when at least one eligible host actually holds the repo.
|
||||||
|
guard !MeshDispatchScorer.rank(
|
||||||
|
candidates: candidates, project: descriptor, backend: nil).isEmpty
|
||||||
|
else { return nil }
|
||||||
|
return (descriptor, candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch a new chat to the abstract **Mesh** destination from the phone: rank eligible
|
||||||
|
/// hosts by the shared scorer and try them in order, one at a time with a correlated,
|
||||||
|
/// idempotent ack (a timeout retries the same candidate once before moving on). On success
|
||||||
|
/// routes to the landed session. Mirrors `AppStore.dispatchChatToMesh`.
|
||||||
|
func dispatchChatToMesh(
|
||||||
|
projectID: ProjectID, message: String, model: String? = nil,
|
||||||
|
effort: String? = nil, auto: Bool? = nil
|
||||||
|
) {
|
||||||
|
let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty, let (descriptor, candidates) =
|
||||||
|
meshDispatchCandidates(forProject: projectID) else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
var excluded: Set<String> = []
|
||||||
|
var tried = 0
|
||||||
|
while true {
|
||||||
|
guard let pick = MeshDispatchScorer.rank(
|
||||||
|
candidates: candidates, project: descriptor,
|
||||||
|
backend: nil, excluding: excluded).first
|
||||||
|
else {
|
||||||
|
showError(tried == 0
|
||||||
|
? "No mesh runner can take this chat right now."
|
||||||
|
: "No mesh runner could take this chat (\(tried) tried).", sessionID: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tried += 1
|
||||||
|
// The phone hands over no clone URL, so every pick is a holder (non-nil id).
|
||||||
|
guard let targetProjectID = pick.projectID,
|
||||||
|
let conn = connections[pick.hostID], conn.connectivity.isLive else {
|
||||||
|
excluded.insert(pick.hostID); continue
|
||||||
|
}
|
||||||
|
let requestID = UUID().uuidString
|
||||||
|
let request = StartChatRequest(
|
||||||
|
projectID: targetProjectID, message: text, model: model, effort: effort,
|
||||||
|
auto: auto, requestID: requestID)
|
||||||
|
var outcome = await sendDispatch(request, on: conn, timeout: .seconds(10))
|
||||||
|
if outcome == nil {
|
||||||
|
outcome = await sendDispatch(request, on: conn, timeout: .seconds(5))
|
||||||
|
}
|
||||||
|
if let sessionID = outcome?.sessionID {
|
||||||
|
// The session lives on the target host; refresh its list and route to it.
|
||||||
|
conn.send(.listSessions)
|
||||||
|
conn.send(.listDashboard)
|
||||||
|
route(to: sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let failure = outcome?.error {
|
||||||
|
showError("\(conn.hostName): \(failure)", sessionID: nil)
|
||||||
|
}
|
||||||
|
excluded.insert(pick.hostID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendDispatch(
|
||||||
|
_ request: StartChatRequest, on conn: HostConnection, timeout: Duration
|
||||||
|
) async -> WireChatStarted? {
|
||||||
|
guard let requestID = request.requestID else { return nil }
|
||||||
|
conn.send(.startChat(request))
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
chatStartedWaiters[requestID] = continuation
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: timeout)
|
||||||
|
self.chatStartedWaiters.removeValue(forKey: requestID)?.resume(returning: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply freshly received casts (live or catch-up) and persist if anything changed.
|
||||||
|
private func applyCasts(_ apply: (inout CastLedger) -> [WireCast]) {
|
||||||
|
var ledger = castLedger
|
||||||
|
let fresh = apply(&ledger)
|
||||||
|
guard !fresh.isEmpty else { return }
|
||||||
|
castLedger = ledger
|
||||||
|
activityFeed = ActivityFeedItem.merged(activityFeed, adding: fresh)
|
||||||
|
castPersistTask?.cancel()
|
||||||
|
castPersistTask = Task { [ledger] in
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await CastCache.save(ledger)
|
||||||
|
}
|
||||||
|
}
|
||||||
private var transcriptPersistTask: Task<Void, Never>?
|
private var transcriptPersistTask: Task<Void, Never>?
|
||||||
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
||||||
@Published private(set) var grantedScope: DeviceScope = .approve
|
@Published private(set) var grantedScope: DeviceScope = .approve
|
||||||
@@ -122,7 +266,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
|
|
||||||
/// "Add a project" (Projects tab): the phone asks a connected host advertising
|
/// "Add a project" (Projects tab): the phone asks a connected host advertising
|
||||||
/// `canCreateProjects` to clone a git URL and register it (CLOUD_RUNTIME §4.3) — how a
|
/// `canCreateProjects` to clone a git URL and register it (CLOUD_RUNTIME §4.3) — how a
|
||||||
/// fresh Antimatter runner gets its first project. The outcome arrives asynchronously as
|
/// fresh Carbon runner gets its first project. The outcome arrives asynchronously as
|
||||||
/// `HostMsg.projectCreated`, correlated by the request id below; the project row itself
|
/// `HostMsg.projectCreated`, correlated by the request id below; the project row itself
|
||||||
/// rides the dashboard push.
|
/// rides the dashboard push.
|
||||||
enum AddProjectState: Equatable {
|
enum AddProjectState: Equatable {
|
||||||
@@ -460,6 +604,10 @@ final class RemoteStore: ObservableObject {
|
|||||||
if isPaired {
|
if isPaired {
|
||||||
// Show the saved chat history immediately, before any host connects.
|
// Show the saved chat history immediately, before any host connects.
|
||||||
if sessions.isEmpty { sessions = cachedSummaries }
|
if sessions.isEmpty { sessions = cachedSummaries }
|
||||||
|
// …and the cached fleet feed (mesh casting) — live catch-up merges on top.
|
||||||
|
if activityFeed.isEmpty {
|
||||||
|
activityFeed = ActivityFeedItem.merged([], adding: castLedger.allCasts)
|
||||||
|
}
|
||||||
// Assume connected while the first handshake completes, so launch doesn't open on a
|
// Assume connected while the first handshake completes, so launch doesn't open on a
|
||||||
// grayed-out "Disconnected" shell for the second it takes to come up.
|
// grayed-out "Disconnected" shell for the second it takes to come up.
|
||||||
beginOptimisticWindow()
|
beginOptimisticWindow()
|
||||||
@@ -924,6 +1072,21 @@ final class RemoteStore: ObservableObject {
|
|||||||
IdentityStore.savePairedHost(host)
|
IdentityStore.savePairedHost(host)
|
||||||
self.rebuildAggregate()
|
self.rebuildAggregate()
|
||||||
}
|
}
|
||||||
|
cb.castCursors = { [weak self] in
|
||||||
|
// Mesh casting: subscribe with the MERGED ledger's tips (every origin), so this host
|
||||||
|
// serves only the gaps — including gaps for origins it merely mirrors.
|
||||||
|
self?.castLedger.cursors ?? []
|
||||||
|
}
|
||||||
|
cb.castsReceived = { [weak self] casts in
|
||||||
|
self?.applyCasts { $0.apply(casts) }
|
||||||
|
}
|
||||||
|
cb.castCatchUp = { [weak self] batch in
|
||||||
|
self?.applyCasts { $0.applyCatchUp(batch) }
|
||||||
|
}
|
||||||
|
cb.chatStarted = { [weak self] outcome in
|
||||||
|
// Mesh dispatch: resume the waiting `dispatchChatToMesh` by requestID.
|
||||||
|
self?.chatStartedWaiters.removeValue(forKey: outcome.requestID)?.resume(returning: outcome)
|
||||||
|
}
|
||||||
cb.meshRosterChanged = { [weak self] in
|
cb.meshRosterChanged = { [weak self] in
|
||||||
// Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac
|
// Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac
|
||||||
// (connecting the newcomer) and drop any that left — without switching the active host.
|
// (connecting the newcomer) and drop any that left — without switching the active host.
|
||||||
@@ -1318,6 +1481,9 @@ final class RemoteStore: ObservableObject {
|
|||||||
// No Mac left whose history to hold — drop the offline cache too.
|
// No Mac left whose history to hold — drop the offline cache too.
|
||||||
cachedSummaries = []
|
cachedSummaries = []
|
||||||
SessionCache.clear()
|
SessionCache.clear()
|
||||||
|
castLedger = CastLedger()
|
||||||
|
activityFeed = []
|
||||||
|
CastCache.clear()
|
||||||
sessions = []
|
sessions = []
|
||||||
dashboard = .empty
|
dashboard = .empty
|
||||||
// Nothing left to dial — spin the embedded Tailscale node down if it was running.
|
// Nothing left to dial — spin the embedded Tailscale node down if it was running.
|
||||||
@@ -1825,7 +1991,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
// Never originated from here (connection-internal, or handled by dedicated loops).
|
// Never originated from here (connection-internal, or handled by dedicated loops).
|
||||||
// `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by
|
// `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by
|
||||||
// `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch.
|
// `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch.
|
||||||
// The runner verbs (intelligence results, credential mesh — ANTIMATTER_RUNNER §5–6) will
|
// The runner verbs (intelligence results, credential mesh — CARBON_RUNNER §5–6) will
|
||||||
// ride their own executor/vault loops when the phone side lands; nothing routes them here.
|
// ride their own executor/vault loops when the phone side lands; nothing routes them here.
|
||||||
// `createProject` (CLOUD_RUNTIME §4.3) will go straight to a user-chosen host when the
|
// `createProject` (CLOUD_RUNTIME §4.3) will go straight to a user-chosen host when the
|
||||||
// phone grows that UI — a brand-new project has no owner to route by.
|
// phone grows that UI — a brand-new project has no owner to route by.
|
||||||
@@ -1833,7 +1999,10 @@ final class RemoteStore: ObservableObject {
|
|||||||
.registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground,
|
.registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground,
|
||||||
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
|
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
|
||||||
.requestPairingCode, .cancelPairingCode, .respondMacPair,
|
.requestPairingCode, .cancelPairingCode, .respondMacPair,
|
||||||
.intelligenceResult, .credentialManifest, .credentialProvision, .createProject:
|
.intelligenceResult, .credentialManifest, .credentialProvision, .createProject,
|
||||||
|
// Cast subscriptions are per-connection (each `HostConnection` subscribes on its
|
||||||
|
// own ready, with the merged ledger's cursors) — nothing routes them here.
|
||||||
|
.castSubscribe:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2021,10 +2190,12 @@ final class RemoteStore: ObservableObject {
|
|||||||
// "Add a device" mint is handled directly against a live host, not via demoHandle;
|
// "Add a device" mint is handled directly against a live host, not via demoHandle;
|
||||||
// the demo path short-circuits in `requestPairingCode()` with a stand-in code.
|
// the demo path short-circuits in `requestPairingCode()` with a stand-in code.
|
||||||
.requestPairingCode, .cancelPairingCode, .respondMacPair,
|
.requestPairingCode, .cancelPairingCode, .respondMacPair,
|
||||||
// Antimatter runner verbs (ANTIMATTER_RUNNER §5–6) — demo has no runner host.
|
// Carbon runner verbs (CARBON_RUNNER §5–6) — demo has no runner host.
|
||||||
.intelligenceResult, .credentialManifest, .credentialProvision,
|
.intelligenceResult, .credentialManifest, .credentialProvision,
|
||||||
// Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on.
|
// Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on.
|
||||||
.createProject:
|
.createProject,
|
||||||
|
// Mesh casting (demo has no host to cast).
|
||||||
|
.castSubscribe:
|
||||||
break // passive / already handled by the seeded fixtures (demo has no mesh peers)
|
break // passive / already handled by the seeded fixtures (demo has no mesh peers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import NucleicProtocol
|
||||||
|
|
||||||
|
/// The phone's fleet activity feed (mesh casting): notable events from every Mac and runner —
|
||||||
|
/// finished/needs-input turns, approvals, ships, transfers, dispatches — merged across origins
|
||||||
|
/// and surviving late joins (an hour-later app open replays the gap). A passive history
|
||||||
|
/// surface: it never alerts; rows deep-link to their session via the notification route.
|
||||||
|
///
|
||||||
|
/// `RecentActivitySection` is the Home dashboard's three-row peek; `ActivityFeedListView` is
|
||||||
|
/// the pushed full list.
|
||||||
|
struct RecentActivitySection: View {
|
||||||
|
@EnvironmentObject var store: RemoteStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !store.activityFeed.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Label("Recent activity", systemImage: "bell")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
NavigationLink {
|
||||||
|
ActivityFeedListView()
|
||||||
|
} label: {
|
||||||
|
Text("See all")
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(store.activityFeed.suffix(3).reversed()) { item in
|
||||||
|
ActivityFeedRow(item: item)
|
||||||
|
if item.id != store.activityFeed.suffix(3).first?.id {
|
||||||
|
Divider().padding(.leading, 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.card()
|
||||||
|
.onAppear { store.markFeedSeen() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActivityFeedListView: View {
|
||||||
|
@EnvironmentObject var store: RemoteStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(store.activityFeed.reversed()) { item in
|
||||||
|
ActivityFeedRow(item: item)
|
||||||
|
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Activity")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.overlay {
|
||||||
|
if store.activityFeed.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No fleet activity yet", systemImage: "bell.slash",
|
||||||
|
description: Text("Finished chats, approvals, ships, and transfers from every Mac and runner land here."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { store.markFeedSeen() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ActivityFeedRow: View {
|
||||||
|
@EnvironmentObject var store: RemoteStore
|
||||||
|
let item: ActivityFeedItem
|
||||||
|
|
||||||
|
private var event: FeedEvent { item.event }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
// Route like a notification tap: jump to the Sessions tab and push the session on
|
||||||
|
// its owning host (works for any mesh host's session the phone mirrors).
|
||||||
|
store.route(to: event.sessionID)
|
||||||
|
} label: {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: Self.icon(for: event.kind))
|
||||||
|
.foregroundStyle(Self.tint(for: event.kind))
|
||||||
|
.font(.subheadline)
|
||||||
|
.frame(width: 20)
|
||||||
|
.padding(.top, 2)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(event.sessionTitle.isEmpty ? "Untitled chat" : event.sessionTitle)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
Text(event.at, format: .relative(presentation: .named))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var subtitle: String {
|
||||||
|
var parts: [String] = [Self.headline(for: event)]
|
||||||
|
if !event.projectName.isEmpty { parts.append(event.projectName) }
|
||||||
|
if !event.hostName.isEmpty { parts.append(event.hostName) }
|
||||||
|
return parts.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func headline(for event: FeedEvent) -> String {
|
||||||
|
switch event.kind {
|
||||||
|
case .finished: "Finished"
|
||||||
|
case .needsInput: "Waiting for you"
|
||||||
|
case .needsApproval: event.detail.map { "Needs approval: \($0)" } ?? "Needs approval"
|
||||||
|
case .shipped: event.detail.map { "Shipped · \($0)" } ?? "Shipped"
|
||||||
|
case .transferredOut: "Moved to \(event.counterpartHostName ?? "another Mac")"
|
||||||
|
case .transferredIn: "Arrived from \(event.counterpartHostName ?? "another Mac")"
|
||||||
|
case .error: event.detail.map { "Error: \($0)" } ?? "Error"
|
||||||
|
case .dispatched: "Dispatched here via Mesh"
|
||||||
|
default: event.kind.rawValue // a newer build's kind — render generically, never drop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func icon(for kind: FeedEvent.Kind) -> String {
|
||||||
|
switch kind {
|
||||||
|
case .finished: "checkmark.circle"
|
||||||
|
case .needsInput: "bubble.left.and.exclamationmark.bubble.right"
|
||||||
|
case .needsApproval: "hand.raised"
|
||||||
|
case .shipped: "arrow.triangle.merge"
|
||||||
|
case .transferredOut: "arrow.up.right.square"
|
||||||
|
case .transferredIn: "arrow.down.left.square"
|
||||||
|
case .error: "exclamationmark.triangle"
|
||||||
|
case .dispatched: "paperplane"
|
||||||
|
default: "circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func tint(for kind: FeedEvent.Kind) -> Color {
|
||||||
|
switch kind {
|
||||||
|
case .finished, .shipped: .green
|
||||||
|
case .needsInput, .needsApproval: .orange
|
||||||
|
case .error: .red
|
||||||
|
default: .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ struct StartChatComposer: View {
|
|||||||
/// project page's "+", so the chat always lands in that project.
|
/// project page's "+", so the chat always lands in that project.
|
||||||
var lockedProject: WireProject? = nil
|
var lockedProject: WireProject? = nil
|
||||||
@State private var projectID: ProjectID?
|
@State private var projectID: ProjectID?
|
||||||
|
/// Mesh dispatch: run on the project's own Mac (false) or auto-route to the best eligible
|
||||||
|
/// mesh host (true). Shown only when a connected peer could actually take it.
|
||||||
|
@State private var runOnMesh = false
|
||||||
@State private var autoOverride: Bool?
|
@State private var autoOverride: Bool?
|
||||||
@State private var model: String?
|
@State private var model: String?
|
||||||
@State private var effort = MobileEfforts.fallback
|
@State private var effort = MobileEfforts.fallback
|
||||||
@@ -39,6 +42,11 @@ struct StartChatComposer: View {
|
|||||||
lockedProject ?? projects.first { $0.id == projectID }
|
lockedProject ?? projects.first { $0.id == projectID }
|
||||||
}
|
}
|
||||||
private var controlled: Bool { selected?.isNucleicControlled ?? false }
|
private var controlled: Bool { selected?.isNucleicControlled ?? false }
|
||||||
|
/// Whether the "Run on: This Mac / Mesh" picker should appear for the current selection.
|
||||||
|
private var meshAvailable: Bool {
|
||||||
|
guard let id = selected?.id else { return false }
|
||||||
|
return store.meshDispatchAvailable(forProject: id)
|
||||||
|
}
|
||||||
/// Auto-approve for the chat about to start: the user's choice off-control, but forced on for a
|
/// Auto-approve for the chat about to start: the user's choice off-control, but forced on for a
|
||||||
/// Nucleic Control project. The host locks Auto on for those chats regardless of what the phone
|
/// Nucleic Control project. The host locks Auto on for those chats regardless of what the phone
|
||||||
/// sends (they run autonomously — nvrsion needs it: see `AppStore.createSession`), so the
|
/// sends (they run autonomously — nvrsion needs it: see `AppStore.createSession`), so the
|
||||||
@@ -79,6 +87,23 @@ struct StartChatComposer: View {
|
|||||||
? "Auto-approve is always on for Nucleic Control chats — they run autonomously."
|
? "Auto-approve is always on for Nucleic Control chats — they run autonomously."
|
||||||
: "Auto-approve safe actions; destructive ones still ask.")
|
: "Auto-approve safe actions; destructive ones still ask.")
|
||||||
}
|
}
|
||||||
|
// Mesh dispatch: when an eligible peer holds this repo, offer "Auto (Mesh)" so the
|
||||||
|
// chat routes to whichever host fits rather than always its own owner.
|
||||||
|
if meshAvailable {
|
||||||
|
Menu {
|
||||||
|
Picker("Run on", selection: $runOnMesh) {
|
||||||
|
Text("This Mac").tag(false)
|
||||||
|
Text("Mesh (automatic)").tag(true)
|
||||||
|
}
|
||||||
|
.pickerStyle(.inline)
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
runOnMesh ? "Mesh" : "This Mac",
|
||||||
|
systemImage: runOnMesh ? "antenna.radiowaves.left.and.right" : "desktopcomputer")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Palette.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
HStack {
|
HStack {
|
||||||
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
|
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -132,12 +157,20 @@ struct StartChatComposer: View {
|
|||||||
.focused(focus)
|
.focused(focus)
|
||||||
Button {
|
Button {
|
||||||
if let project = selected {
|
if let project = selected {
|
||||||
let branch = baseBranch.trimmingCharacters(in: .whitespaces)
|
if runOnMesh && meshAvailable {
|
||||||
store.startChat(
|
// Mesh dispatch: route to the best eligible host (attachments can't
|
||||||
in: project.id, message: draft, model: model, effort: effort,
|
// ride a dispatch, same as a remote start).
|
||||||
baseBranch: branch.isEmpty ? nil : branch,
|
store.dispatchChatToMesh(
|
||||||
useWorktree: useWorktree, auto: effectiveAuto,
|
projectID: project.id, message: draft, model: model,
|
||||||
attachments: attachments.wireAttachments)
|
effort: effort, auto: effectiveAuto)
|
||||||
|
} else {
|
||||||
|
let branch = baseBranch.trimmingCharacters(in: .whitespaces)
|
||||||
|
store.startChat(
|
||||||
|
in: project.id, message: draft, model: model, effort: effort,
|
||||||
|
baseBranch: branch.isEmpty ? nil : branch,
|
||||||
|
useWorktree: useWorktree, auto: effectiveAuto,
|
||||||
|
attachments: attachments.wireAttachments)
|
||||||
|
}
|
||||||
draft = ""
|
draft = ""
|
||||||
attachments = []
|
attachments = []
|
||||||
attachmentsOverflowed = false
|
attachmentsOverflowed = false
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ struct HomeView: View {
|
|||||||
|
|
||||||
if !running.isEmpty { InProgressSessions(sessions: running) }
|
if !running.isEmpty { InProgressSessions(sessions: running) }
|
||||||
|
|
||||||
|
// Fleet activity (mesh casting): the merged cross-host feed's newest three
|
||||||
|
// rows, with "See all" pushing the full list. Renders offline from the cache.
|
||||||
|
RecentActivitySection()
|
||||||
|
|
||||||
QuickTodos()
|
QuickTodos()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ struct ProjectsView: View {
|
|||||||
.refreshable { store.refreshSessions() }
|
.refreshable { store.refreshSessions() }
|
||||||
.toolbar {
|
.toolbar {
|
||||||
// "Add a project" (CLOUD_RUNTIME §4.3): clone a git URL on a connected host —
|
// "Add a project" (CLOUD_RUNTIME §4.3): clone a git URL on a connected host —
|
||||||
// how a fresh Antimatter runner gets its first project. Hidden when no live
|
// how a fresh Carbon runner gets its first project. Hidden when no live
|
||||||
// host advertises `canCreateProjects` (an older Mac would reject the verb).
|
// host advertises `canCreateProjects` (an older Mac would reject the verb).
|
||||||
if store.canCreateProject {
|
if store.canCreateProject {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ struct SettingsView: View {
|
|||||||
Button {
|
Button {
|
||||||
showScanner = true
|
showScanner = true
|
||||||
} label: {
|
} label: {
|
||||||
Label("Join an Antimatter mesh", systemImage: "qrcode.viewfinder")
|
Label("Join a Carbon mesh", systemImage: "circle.dotted.and.circle")
|
||||||
}
|
}
|
||||||
Button {
|
Button {
|
||||||
showManualPair = true
|
showManualPair = true
|
||||||
@@ -454,7 +454,7 @@ struct AddDeviceView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Scan to join")
|
Text("Scan to join")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("On a new iPhone or iPad, open Nucleic Remote ▸ Join an Antimatter mesh and scan this. "
|
Text("On a new iPhone or iPad, open Nucleic Remote ▸ Join a Carbon mesh and scan this. "
|
||||||
+ "It joins the whole group — every Mac and device here.")
|
+ "It joins the whole group — every Mac and device here.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user