feat(v1.1.6): realtime channels + v1.1.5 follow-ups + version bumps

Server-side realtime SSE on per-app pub/sub topics, plus the three
v1.1.5 follow-ups and the version bumps.

Realtime:
- topics registry (0021) + admin endpoints + Capability::AppTopicManage
  (-> app:admin; no new scope).
- GET /realtime/topics/{topic} SSE endpoint (orchestrator-core data
  plane): Host -> app, RealtimeAuthority gate (404 missing/internal,
  401 bad/absent token), broadcast::Receiver stream + heartbeat.
- RealtimeBroadcaster / RealtimeEvent / RealtimeAuthority traits
  (picloud-shared); InProcessBroadcaster + GC (orchestrator-core);
  DB-backed RealtimeAuthorityImpl (manager-core). Publish path fans out
  to in-process subscribers after the durable outbox commit (best-effort,
  panic-isolated).
- HMAC subscriber tokens (subscriber_token.rs) + app_secrets table (0022)
  + pubsub::subscriber_token SDK (schema 1.6 -> 1.7). TTL clamp + env
  overrides.
- Dashboard Topics tab (register/list/edit/delete, prominent external
  badge, flip confirmation).

v1.1.5 follow-ups:
- Empty blobs accepted (NewFile/FileUpdate::validate) + round-trip test.
- Orphan *.tmp.* sweeper (spawn_files_orphan_sweep).
- Dispatcher e2e tests, one per trigger kind (DATABASE_URL-gated).

Versions: workspace 1.1.6, SDK 1.7, dashboard 0.12.0. Schema-snapshot
golden re-blessed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-04 20:18:50 +02:00
parent d064681c49
commit fcbcc576a2
35 changed files with 4333 additions and 63 deletions

View File

@@ -0,0 +1,86 @@
//! `RealtimeBroadcaster` — the in-process fan-out seam for SSE realtime
//! delivery (v1.1.6).
//!
//! Structurally a sibling of [`crate::inbox::InboxResolver`]: the trait
//! lives here in `picloud-shared` because the publish side
//! (`PubsubServiceImpl` in manager-core) and the subscribe side (the SSE
//! handler in orchestrator-core) live in different crates and both need
//! one shared instance. The in-process impl lives in orchestrator-core
//! (`Mutex<HashMap<(AppId, topic), broadcast::Sender>>`); cluster mode
//! (v1.3+) swaps it for a Postgres `LISTEN/NOTIFY`-backed resolver behind
//! the same trait without touching either caller.
//!
//! Delivery is **best-effort, at-most-once**: this is the realtime path,
//! NOT the durable one. Durable trigger fan-out (retry / dead-letter)
//! goes through the outbox and is the publish caller's separate concern.
//! A slow SSE consumer loses the oldest events (bounded broadcast
//! buffer); SSE's own transport-layer auto-reconnect is the recovery
//! mechanism (no server-side replay in v1.1.6).
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use thiserror::Error;
use tokio::sync::broadcast;
use crate::AppId;
/// A single realtime event delivered to in-process SSE subscribers. The
/// SSE handler serializes this to `data: {...}\n\n` on the wire.
#[derive(Debug, Clone)]
pub struct RealtimeEvent {
pub topic: String,
pub message: serde_json::Value,
pub published_at: DateTime<Utc>,
}
#[derive(Debug, Error)]
pub enum BroadcasterError {
/// Reserved for backends that can fail to register a subscriber
/// (e.g. the cluster-mode `LISTEN/NOTIFY` resolver). The in-process
/// impl never returns this.
#[error("realtime broadcaster unavailable: {0}")]
Unavailable(String),
}
#[async_trait]
pub trait RealtimeBroadcaster: Send + Sync {
/// Subscribe to events on `(app_id, topic)`. Returns a receiver that
/// yields events until dropped. Channels are created lazily on first
/// subscribe.
async fn subscribe(
&self,
app_id: AppId,
topic: &str,
) -> Result<broadcast::Receiver<RealtimeEvent>, BroadcasterError>;
/// Publish an event to in-process subscribers. NOT durable — the
/// outbox-backed durable fan-out is the publish caller's separate
/// concern. A publish with no live subscribers is a silent no-op.
async fn publish(&self, app_id: AppId, topic: &str, event: RealtimeEvent);
/// Drop every subscriber for a topic (called on topic DELETE). Live
/// receivers observe a closed channel and disconnect cleanly.
async fn drop_topic(&self, app_id: AppId, topic: &str);
}
/// Bootstrap / test impl: subscribe yields a receiver on a throwaway
/// channel, publish is a no-op. Lets a `Services`-style bundle build
/// without the real registry wired in.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopRealtimeBroadcaster;
#[async_trait]
impl RealtimeBroadcaster for NoopRealtimeBroadcaster {
async fn subscribe(
&self,
_app_id: AppId,
_topic: &str,
) -> Result<broadcast::Receiver<RealtimeEvent>, BroadcasterError> {
let (_tx, rx) = broadcast::channel(1);
Ok(rx)
}
async fn publish(&self, _app_id: AppId, _topic: &str, _event: RealtimeEvent) {}
async fn drop_topic(&self, _app_id: AppId, _topic: &str) {}
}