Skip to main content

supercode_harness/
runtime_registry.rs

1//! Authenticated inventory and attachment for live and persisted sessions.
2//!
3//! The registry joins durable harness discovery with private live-runtime
4//! receipts. Receipts remain local routing hints: canonical/native sessions,
5//! sidecars, and exports are never deleted during stale reconciliation.
6
7use std::collections::{BTreeMap, BTreeSet, HashMap};
8use std::path::PathBuf;
9use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
10use std::time::{Duration, Instant};
11
12use serde::{Deserialize, Serialize};
13
14use crate::catalog::StorageLocator;
15use crate::{
16    find_live_runtime, forget_live_runtime, list_live_runtimes, resolve_live_runtime,
17    DiscoveryQuery, FrontendActions, FrontendConnectionState, FrontendRuntimeDescriptor,
18    FrontendTurnState, HarnessCatalog, HttpFrontendRuntime, LiveRuntimeEndpoint,
19    RuntimeAuthorization, RuntimeClientId, RuntimeControllerLease, RuntimeObserverLease,
20    RuntimePermission, SdkError, SdkOperation, Session,
21};
22
23/// Filters controlling one joined live/persisted inventory read.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(default)]
26pub struct RuntimeRegistryQuery {
27    /// Persisted harness discovery filters and roots.
28    pub persisted: DiscoveryQuery,
29    /// Include active SDK runtimes.
30    pub include_live: bool,
31    /// Include durable sessions which are not necessarily live.
32    pub include_persisted: bool,
33}
34
35impl Default for RuntimeRegistryQuery {
36    fn default() -> Self {
37        Self {
38            persisted: DiscoveryQuery::default(),
39            include_live: true,
40            include_persisted: true,
41        }
42    }
43}
44
45/// Reconciled lifecycle state reported by list/describe/watch.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum RuntimeRegistryState {
49    /// Durable session exists but has no registered live runtime.
50    Persisted,
51    /// Live runtime is ready for a turn.
52    Idle,
53    /// Live runtime owns an active turn.
54    Busy,
55    /// Live runtime is shutting down.
56    ShuttingDown,
57}
58
59impl RuntimeRegistryState {
60    /// Stable wire token, identical to this value's serde representation.
61    pub fn as_str(self) -> &'static str {
62        match self {
63            Self::Persisted => "persisted",
64            Self::Idle => "idle",
65            Self::Busy => "busy",
66            Self::ShuttingDown => "shutting_down",
67        }
68    }
69}
70
71/// Process and controller ownership for one live entry.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct RuntimeRegistryOwner {
74    /// Local process that owns execution and persistence.
75    pub pid: u32,
76    /// Current frontend controller lease, if any.
77    pub controller: Option<RuntimeControllerLease>,
78}
79
80/// Stable joined descriptor returned by the registry.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct RuntimeRegistryEntry {
83    /// Stable selector. Live entries use the SDK runtime id; persisted entries
84    /// use `<harness>:<native-session-id>`.
85    pub id: String,
86    /// Stable SDK runtime id when live.
87    pub runtime_id: Option<String>,
88    /// Harness-native source session id.
89    pub source_session_id: String,
90    /// Workspace owned by the runtime/source identity.
91    pub source_workspace: Option<PathBuf>,
92    /// Source harness.
93    pub source_harness: String,
94    /// Resolved emulation profile.
95    pub profile: Option<String>,
96    /// Current durable/live state.
97    pub state: RuntimeRegistryState,
98    /// Current model label when live, otherwise lightweight persisted metadata.
99    pub model: Option<String>,
100    /// Runtime process/controller ownership.
101    pub owner: Option<RuntimeRegistryOwner>,
102    /// Attached observer leases in stable client-id order.
103    pub observers: Vec<RuntimeObserverLease>,
104    /// Live registration time.
105    pub started_at_ms: Option<u128>,
106    /// Last persisted update time.
107    pub updated_at_ms: Option<u64>,
108    /// Opaque live endpoint safe to display.
109    pub endpoint: Option<LiveRuntimeEndpoint>,
110    /// Available endpoint transports such as HTTP and ACP.
111    pub endpoint_capabilities: Vec<String>,
112    /// Actions permitted by the credential used for this registry read.
113    pub actions: Option<FrontendActions>,
114    /// Canonical or native durable location; never inferred from a tmux pane.
115    pub persistence_location: Option<PathBuf>,
116    /// Optional local process supervisor. Never used as session authority.
117    pub supervisor: Option<crate::LiveRuntimeSupervisor>,
118    /// Optional persisted title.
119    pub title: Option<String>,
120}
121
122/// Change emitted by [`RuntimeRegistryWatch`].
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(tag = "kind", rename_all = "snake_case")]
125pub enum RuntimeRegistryEvent {
126    /// New stable entry.
127    Added {
128        /// Complete current descriptor.
129        entry: RuntimeRegistryEntry,
130    },
131    /// Existing entry changed state, ownership, metadata, or capabilities.
132    Updated {
133        /// Complete replacement descriptor.
134        entry: RuntimeRegistryEntry,
135    },
136    /// Entry disappeared after close or stale reconciliation.
137    Removed {
138        /// Stable id that disappeared.
139        id: String,
140    },
141    /// A polling iteration failed without terminating the watch.
142    Error {
143        /// Stable human-readable failure detail.
144        message: String,
145    },
146}
147
148/// Bounded watch subscription. Dropping it stops the polling task.
149pub struct RuntimeRegistryWatch {
150    receiver: tokio::sync::mpsc::Receiver<RuntimeRegistryEvent>,
151    task: tokio::task::JoinHandle<()>,
152}
153
154impl RuntimeRegistryWatch {
155    /// Receive the next registry change.
156    pub async fn next(&mut self) -> Option<RuntimeRegistryEvent> {
157        self.receiver.recv().await
158    }
159}
160
161impl Drop for RuntimeRegistryWatch {
162    fn drop(&mut self) {
163        self.task.abort();
164    }
165}
166
167/// Local authenticated registry backed by harness discovery and private
168/// live-runtime receipts.
169#[derive(Debug, Clone, Copy, Default)]
170pub struct LocalRuntimeRegistry;
171
172impl LocalRuntimeRegistry {
173    /// Construct a stateless registry facade.
174    pub fn new() -> Self {
175        Self
176    }
177
178    /// List live and/or persisted sessions after enforcing observe authority.
179    pub async fn list(
180        &self,
181        query: &RuntimeRegistryQuery,
182        authorization: &RuntimeAuthorization,
183    ) -> Result<Vec<RuntimeRegistryEntry>, SdkError> {
184        require_permission(authorization, RuntimePermission::Observe)?;
185        let mut entries = BTreeMap::<String, RuntimeRegistryEntry>::new();
186        if query.include_persisted {
187            let persisted = HarnessCatalog::new()
188                .discover(&query.persisted)
189                .map_err(|error| SdkError::Execution {
190                    operation: SdkOperation::Discover,
191                    message: error.to_string(),
192                })?;
193            for descriptor in persisted {
194                let id = format!(
195                    "{}:{}",
196                    descriptor.locator.harness.as_str(),
197                    descriptor.locator.session_id
198                );
199                let persistence_location = Some(match &descriptor.locator.storage {
200                    StorageLocator::File { path } | StorageLocator::Sqlite { path, .. } => {
201                        path.clone()
202                    }
203                });
204                entries.insert(
205                    id.clone(),
206                    RuntimeRegistryEntry {
207                        id,
208                        runtime_id: None,
209                        source_session_id: descriptor.locator.session_id,
210                        source_workspace: None,
211                        source_harness: descriptor.locator.harness.0,
212                        profile: None,
213                        state: RuntimeRegistryState::Persisted,
214                        model: descriptor.model,
215                        owner: None,
216                        observers: Vec::new(),
217                        started_at_ms: None,
218                        updated_at_ms: descriptor.updated_at_ms,
219                        endpoint: None,
220                        endpoint_capabilities: Vec::new(),
221                        actions: None,
222                        persistence_location,
223                        supervisor: None,
224                        title: descriptor.title,
225                    },
226                );
227            }
228        }
229        if query.include_live {
230            for record in list_live_runtimes().map_err(registry_receipt_error)? {
231                let Some((probe, descriptor)) = probe_receipt(&record).await? else {
232                    continue;
233                };
234                let leases = probe.lease_snapshot().await?;
235                let entry = live_entry(record, descriptor, leases);
236                if entries.insert(entry.id.clone(), entry).is_some() {
237                    return Err(SdkError::Execution {
238                        operation: SdkOperation::Discover,
239                        message: "duplicate stable runtime id in live registry".into(),
240                    });
241                }
242            }
243        }
244        Ok(entries.into_values().collect())
245    }
246
247    /// Describe one stable entry without attaching an observer.
248    pub async fn describe(
249        &self,
250        id: &str,
251        query: &RuntimeRegistryQuery,
252        authorization: &RuntimeAuthorization,
253    ) -> Result<RuntimeRegistryEntry, SdkError> {
254        self.list(query, authorization)
255            .await?
256            .into_iter()
257            .find(|entry| entry.id == id)
258            .ok_or_else(|| SdkError::NotFound {
259                operation: SdkOperation::Discover,
260                message: format!("runtime or persisted session `{id}`"),
261            })
262    }
263
264    /// Reconciled lifecycle state of the live runtime registered for one
265    /// persisted source session.
266    ///
267    /// `None` means no live Supercode runtime is registered for that identity —
268    /// a harness running outside Supercode leaves no receipt, so its activity
269    /// is unknowable and is never guessed at. An endpoint that does not answer
270    /// is reported as `None` for that read, and its receipt is reconciled away
271    /// under exactly the policy [`Self::list`] uses.
272    pub async fn source_state(
273        &self,
274        harness: &str,
275        session_id: &str,
276        authorization: &RuntimeAuthorization,
277    ) -> Result<Option<RuntimeRegistryState>, SdkError> {
278        require_permission(authorization, RuntimePermission::Observe)?;
279        for record in list_live_runtimes().map_err(registry_receipt_error)? {
280            if record.source.harness != harness || record.source.session_id != session_id {
281                continue;
282            }
283            let Some((_probe, descriptor)) = probe_receipt(&record).await? else {
284                continue;
285            };
286            return Ok(Some(reconciled_state(&descriptor)));
287        }
288        Ok(None)
289    }
290
291    /// Sample only the requested source identities in one receipt scan. Activity
292    /// polls need fresh lifecycle evidence, but neither unrelated runtime probes
293    /// nor the controller/observer leases fetched by a full inventory read.
294    pub(crate) async fn source_states(
295        &self,
296        sources: &BTreeSet<(String, String)>,
297        authorization: &RuntimeAuthorization,
298    ) -> Result<BTreeMap<(String, String), RuntimeRegistryState>, SdkError> {
299        require_permission(authorization, RuntimePermission::Observe)?;
300        if sources.is_empty() {
301            return Ok(BTreeMap::new());
302        }
303        // Retain list's stable-runtime ordering and duplicate-id validation
304        // before collapsing multiple runtimes for the same source identity.
305        let mut runtimes = BTreeMap::new();
306        for record in list_live_runtimes().map_err(registry_receipt_error)? {
307            let key = (
308                record.source.harness.clone(),
309                record.source.session_id.clone(),
310            );
311            if !sources.contains(&key) {
312                continue;
313            }
314            let Some((_probe, descriptor)) = probe_receipt(&record).await? else {
315                continue;
316            };
317            if runtimes
318                .insert(
319                    record.runtime_session_id,
320                    (key, reconciled_state(&descriptor)),
321                )
322                .is_some()
323            {
324                return Err(SdkError::Execution {
325                    operation: SdkOperation::Discover,
326                    message: "duplicate stable runtime id in live registry".into(),
327                });
328            }
329        }
330        Ok(runtimes.into_values().collect())
331    }
332
333    /// Attach an authenticated SDK client to one live runtime.
334    pub async fn attach(
335        &self,
336        runtime_id: &str,
337        client_id: RuntimeClientId,
338        authorization: RuntimeAuthorization,
339    ) -> Result<Arc<HttpFrontendRuntime>, SdkError> {
340        require_permission(&authorization, RuntimePermission::Observe)?;
341        let record = find_live_runtime(runtime_id)
342            .map_err(registry_receipt_error)?
343            .ok_or_else(|| SdkError::NotFound {
344                operation: SdkOperation::Resume,
345                message: format!("live runtime `{runtime_id}`"),
346            })?;
347        let resolved = resolve_live_runtime(&record.endpoint, &record.source)
348            .map_err(registry_receipt_error)?;
349        let attached = HttpFrontendRuntime::connect_with_authorization(
350            resolved.base_url,
351            resolved.token,
352            client_id,
353            authorization,
354        )
355        .await?;
356        // A completed attachment is the strongest liveness evidence this
357        // module can have — the receipt just did the job it exists for — so it
358        // ends any outage a passing probe failure had opened.
359        note_reachable(&record.endpoint);
360        Ok(attached)
361    }
362
363    /// Load one persisted descriptor through the same catalog used by list.
364    pub fn load_persisted(
365        &self,
366        id: &str,
367        query: &RuntimeRegistryQuery,
368        authorization: &RuntimeAuthorization,
369    ) -> Result<Session, SdkError> {
370        require_permission(authorization, RuntimePermission::Observe)?;
371        let descriptor = HarnessCatalog::new()
372            .discover(&query.persisted)
373            .map_err(|error| SdkError::Execution {
374                operation: SdkOperation::Discover,
375                message: error.to_string(),
376            })?
377            .into_iter()
378            .find(|descriptor| {
379                format!(
380                    "{}:{}",
381                    descriptor.locator.harness.as_str(),
382                    descriptor.locator.session_id
383                ) == id
384            })
385            .ok_or_else(|| SdkError::NotFound {
386                operation: SdkOperation::Load,
387                message: format!("persisted session `{id}`"),
388            })?;
389        HarnessCatalog::new()
390            .load(&descriptor.locator)
391            .map_err(|error| SdkError::Execution {
392                operation: SdkOperation::Load,
393                message: error.to_string(),
394            })
395    }
396
397    /// Watch joined registry state through a bounded change stream.
398    pub fn watch(
399        &self,
400        query: RuntimeRegistryQuery,
401        authorization: RuntimeAuthorization,
402        poll_interval: Duration,
403    ) -> Result<RuntimeRegistryWatch, SdkError> {
404        require_permission(&authorization, RuntimePermission::Observe)?;
405        let (sender, receiver) = tokio::sync::mpsc::channel(128);
406        let registry = *self;
407        let interval = poll_interval.max(Duration::from_millis(25));
408        let task = tokio::spawn(async move {
409            let mut previous = BTreeMap::<String, RuntimeRegistryEntry>::new();
410            let mut ticker = tokio::time::interval(interval);
411            loop {
412                ticker.tick().await;
413                let current = match registry.list(&query, &authorization).await {
414                    Ok(entries) => entries
415                        .into_iter()
416                        .map(|entry| (entry.id.clone(), entry))
417                        .collect::<BTreeMap<_, _>>(),
418                    Err(error) => {
419                        if sender
420                            .send(RuntimeRegistryEvent::Error {
421                                message: error.to_string(),
422                            })
423                            .await
424                            .is_err()
425                        {
426                            return;
427                        }
428                        continue;
429                    }
430                };
431                for (id, entry) in &current {
432                    let event = match previous.get(id) {
433                        None => Some(RuntimeRegistryEvent::Added {
434                            entry: entry.clone(),
435                        }),
436                        Some(prior) if prior != entry => Some(RuntimeRegistryEvent::Updated {
437                            entry: entry.clone(),
438                        }),
439                        Some(_) => None,
440                    };
441                    if let Some(event) = event {
442                        if sender.send(event).await.is_err() {
443                            return;
444                        }
445                    }
446                }
447                for id in previous.keys().filter(|id| !current.contains_key(*id)) {
448                    if sender
449                        .send(RuntimeRegistryEvent::Removed { id: id.clone() })
450                        .await
451                        .is_err()
452                    {
453                        return;
454                    }
455                }
456                previous = current;
457            }
458        });
459        Ok(RuntimeRegistryWatch { receiver, task })
460    }
461}
462
463/// Failed probes within one outage before a receipt is forgotten.
464const FORGET_AFTER_FAILED_PROBES: u32 = 3;
465/// How long one outage must last before its receipt is forgotten, and — the
466/// same bound, deliberately — how far apart two failures may be and still
467/// belong to the same outage. A gap wider than this is a stretch the runtime
468/// was not observed to be down for, so it ends the outage rather than
469/// extending it.
470const FORGET_AFTER_UNREACHABLE_FOR: Duration = Duration::from_secs(2);
471
472/// Reach one live receipt, and the ONE place a receipt is ever forgotten.
473/// Every registry read — list, describe, watch, and the followed-session
474/// projection — goes through this, so the reaping rule cannot fork.
475///
476/// `Ok(None)` means the runtime did not answer this read: the caller reports
477/// it exactly as it reports a session with no receipt at all, which keeps a
478/// genuinely gone runtime reconciling to `persisted` immediately.
479///
480/// Forgetting is the destructive half, and it is deliberately slower. Nothing
481/// re-announces a runtime — the receipt is written once at registration — so a
482/// forgotten receipt costs the frontend its route to attach for the rest of
483/// that runtime's life. A single failed probe is therefore treated as a
484/// hiccup, not as evidence: a receipt goes only after
485/// `FORGET_AFTER_FAILED_PROBES` failures within ONE outage spanning at least
486/// `FORGET_AFTER_UNREACHABLE_FOR`. Both bounds are needed, because a count
487/// alone means whatever the caller's poll rate makes it mean (4 Hz on a
488/// `harness serve` tick, seconds apart in a watch), and a duration alone would
489/// still act on one unlucky probe.
490///
491/// "One outage" is the load-bearing word, and it is bounded from both ends.
492/// Any successful contact through the receipt ends it — a probe here, and an
493/// [`LocalRuntimeRegistry::attach`], which is the strongest liveness evidence
494/// there is because it is the operation the receipt exists to serve. So does a
495/// gap wider than `FORGET_AFTER_UNREACHABLE_FOR` between two failures, which
496/// is a stretch nothing observed the runtime to be down for. Without both, the
497/// count degenerates into "three unlucky hiccups, however far apart", which
498/// destroys the receipt of a runtime that was up — and serving attaches —
499/// between them.
500///
501/// The tally is per process, so a one-shot read — a single `runtime list` from
502/// the CLI — leaves a stale receipt behind instead of reaping it, and so does
503/// a reader that samples more slowly than the outage window, which can never
504/// see two failures close enough together to corroborate. That is the intended
505/// trade: such a reader still omits the runtime from its output, a receipt
506/// whose owning process is gone is already removed when it is read, and the
507/// file costs nothing until a reader that does sample fast enough — the serve
508/// tick — corroborates the failure and removes it.
509async fn probe_receipt(
510    record: &crate::LiveRuntimeRecord,
511) -> Result<Option<(Arc<HttpFrontendRuntime>, FrontendRuntimeDescriptor)>, SdkError> {
512    let Ok(resolved) = resolve_live_runtime(&record.endpoint, &record.source) else {
513        note_unreachable(&record.endpoint);
514        return Ok(None);
515    };
516    let probe_id = registry_probe_id(&record.endpoint)?;
517    match HttpFrontendRuntime::probe_described(resolved.base_url, resolved.token, probe_id).await {
518        Ok(probed) => {
519            note_reachable(&record.endpoint);
520            Ok(Some(probed))
521        }
522        // A live PID whose loopback endpoint has stopped answering for good is
523        // a stale routing record. Removing it never addresses durable session
524        // or sidecar paths.
525        Err(_) => {
526            note_unreachable(&record.endpoint);
527            Ok(None)
528        }
529    }
530}
531
532/// One in-progress outage: when it started, when it was last confirmed, and
533/// how many probes have failed inside it.
534struct Outage {
535    started: Instant,
536    latest: Instant,
537    failures: u32,
538}
539
540/// Outages in progress, keyed by opaque endpoint. Callers lock it for the
541/// duration of one update and never hold the guard across an await or an
542/// unlink.
543fn outages() -> &'static Mutex<HashMap<String, Outage>> {
544    static OUTAGES: OnceLock<Mutex<HashMap<String, Outage>>> = OnceLock::new();
545    OUTAGES.get_or_init(|| Mutex::new(HashMap::new()))
546}
547
548fn lock_outages() -> MutexGuard<'static, HashMap<String, Outage>> {
549    outages()
550        .lock()
551        .unwrap_or_else(std::sync::PoisonError::into_inner)
552}
553
554/// Record that this endpoint answered. Any successful contact ends whatever
555/// outage was in progress, which is why [`LocalRuntimeRegistry::attach`] calls
556/// this too: a receipt that just served an attachment is demonstrably a good
557/// route, and letting hiccups either side of it accumulate would destroy it.
558fn note_reachable(endpoint: &LiveRuntimeEndpoint) {
559    lock_outages().remove(endpoint.as_str());
560}
561
562/// Record that this endpoint did not answer, and forget its receipt once the
563/// outage is corroborated. This is the ONLY place a receipt is ever forgotten.
564fn note_unreachable(endpoint: &LiveRuntimeEndpoint) {
565    let now = Instant::now();
566    let corroborated = {
567        let mut outages = lock_outages();
568        // A failure further from the previous one than the outage window is
569        // not part of that outage: nothing observed the runtime to be down in
570        // between, and it may well have been serving. Dropping the entry here
571        // is what makes this failure start a fresh outage, and it is also what
572        // bounds the map — an entry outlives its last failure by one window.
573        outages
574            .retain(|_, outage| now.duration_since(outage.latest) <= FORGET_AFTER_UNREACHABLE_FOR);
575        let outage = outages
576            .entry(endpoint.as_str().to_string())
577            .or_insert(Outage {
578                started: now,
579                latest: now,
580                failures: 0,
581            });
582        outage.failures += 1;
583        outage.latest = now;
584        let corroborated = outage.failures >= FORGET_AFTER_FAILED_PROBES
585            && now.duration_since(outage.started) >= FORGET_AFTER_UNREACHABLE_FOR;
586        if corroborated {
587            outages.remove(endpoint.as_str());
588        }
589        corroborated
590    };
591    // Unlink outside the lock: every other endpoint's update would otherwise
592    // queue behind this one's filesystem call.
593    if corroborated {
594        let _ = forget_live_runtime(endpoint);
595    }
596}
597
598/// The one mapping from a live runtime's own report to the registry's
599/// reconciled lifecycle state. Every reader — list, describe, watch, and the
600/// followed-session projection — goes through it.
601fn reconciled_state(descriptor: &FrontendRuntimeDescriptor) -> RuntimeRegistryState {
602    if descriptor.connection_state == FrontendConnectionState::ShuttingDown {
603        RuntimeRegistryState::ShuttingDown
604    } else if descriptor.turn_state == FrontendTurnState::Busy {
605        RuntimeRegistryState::Busy
606    } else {
607        RuntimeRegistryState::Idle
608    }
609}
610
611fn registry_probe_id(endpoint: &LiveRuntimeEndpoint) -> Result<RuntimeClientId, SdkError> {
612    RuntimeClientId::parse(format!(
613        "registry-{}",
614        endpoint.as_str().rsplit('/').next().unwrap_or("probe")
615    ))
616    .map_err(|error| SdkError::InvalidArgument {
617        operation: SdkOperation::Discover,
618        message: error.to_string(),
619    })
620}
621
622fn live_entry(
623    record: crate::LiveRuntimeRecord,
624    descriptor: FrontendRuntimeDescriptor,
625    leases: crate::RuntimeLeaseSnapshot,
626) -> RuntimeRegistryEntry {
627    let state = reconciled_state(&descriptor);
628    RuntimeRegistryEntry {
629        id: record.runtime_session_id.clone(),
630        runtime_id: Some(record.runtime_session_id),
631        source_session_id: record.source.session_id,
632        source_workspace: Some(record.source.workspace),
633        source_harness: record.source.harness,
634        profile: descriptor
635            .emulation_profile
636            .or(record.metadata.profile.clone()),
637        state,
638        model: Some(descriptor.model),
639        owner: Some(RuntimeRegistryOwner {
640            pid: record.pid,
641            controller: leases.controller,
642        }),
643        observers: leases.observers,
644        started_at_ms: Some(record.created_at_ms),
645        updated_at_ms: None,
646        endpoint: Some(record.endpoint),
647        endpoint_capabilities: record.metadata.endpoint_capabilities,
648        actions: Some(descriptor.actions),
649        persistence_location: record.metadata.persistence_location,
650        supervisor: record.metadata.supervisor,
651        title: None,
652    }
653}
654
655fn require_permission(
656    authorization: &RuntimeAuthorization,
657    permission: RuntimePermission,
658) -> Result<(), SdkError> {
659    if authorization.allows(permission) {
660        Ok(())
661    } else {
662        Err(SdkError::Unauthorized {
663            permission: permission.as_str().into(),
664        })
665    }
666}
667
668fn registry_receipt_error(error: crate::LiveRuntimeReceiptError) -> SdkError {
669    SdkError::Execution {
670        operation: SdkOperation::Discover,
671        message: error.to_string(),
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678    use crate::server::{run_http, RpcEngine};
679    use crate::{
680        register_live_runtime_with_metadata, Agent, ChatMessage, ChatRequest, Config, HarnessHomes,
681        HarnessId, LiveRuntimeMetadata, LiveRuntimeSource, Provider, SdkRuntime, Usage,
682    };
683    use async_trait::async_trait;
684
685    struct SaysProvider;
686
687    #[async_trait]
688    impl Provider for SaysProvider {
689        async fn complete(
690            &self,
691            _request: &ChatRequest,
692            _on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
693        ) -> crate::Result<(ChatMessage, Usage)> {
694            Ok((ChatMessage::assistant("registry reply"), Usage::default()))
695        }
696    }
697
698    fn root(label: &str) -> PathBuf {
699        let nonce = std::time::SystemTime::now()
700            .duration_since(std::time::UNIX_EPOCH)
701            .unwrap()
702            .as_nanos();
703        let path = std::env::temp_dir().join(format!(
704            "supercode-runtime-registry-{label}-{}-{}",
705            std::process::id(),
706            nonce
707        ));
708        std::fs::create_dir_all(&path).unwrap();
709        path
710    }
711
712    #[tokio::test]
713    #[allow(clippy::await_holding_lock)]
714    async fn joined_registry_lists_watches_attaches_and_reconciles_without_data_loss() {
715        let _guard = crate::live_runtime::test_environment_lock();
716        let home = root("live");
717        let workspace = home.join("workspace");
718        std::fs::create_dir_all(&workspace).unwrap();
719        let persisted = home.join("canonical.jsonl");
720        std::fs::write(&persisted, "SOURCE_BYTES_MUST_SURVIVE\n").unwrap();
721        std::env::set_var("SUPERCODE_HOME", &home);
722
723        let agent = Agent::with_provider(
724            Config::builder().cwd(workspace.clone()).build(),
725            Box::new(SaysProvider),
726        );
727        let engine = RpcEngine::new_named(agent, "live-registry-1", None);
728        let token: Arc<str> = "registry-owner-token".into();
729        let address = run_http(engine.clone(), "127.0.0.1:0", token.clone())
730            .await
731            .unwrap();
732        let registry = LocalRuntimeRegistry::new();
733        let query = RuntimeRegistryQuery {
734            include_live: true,
735            include_persisted: false,
736            ..RuntimeRegistryQuery::default()
737        };
738        let mut watch = registry
739            .watch(
740                query.clone(),
741                RuntimeAuthorization::observer(),
742                Duration::from_millis(25),
743            )
744            .unwrap();
745        let registration = register_live_runtime_with_metadata(
746            "live-registry-1",
747            LiveRuntimeSource {
748                harness: "claude-code".into(),
749                session_id: "source-1".into(),
750                workspace: workspace.clone(),
751            },
752            format!("http://{address}"),
753            token.to_string(),
754            LiveRuntimeMetadata {
755                profile: Some("cc-parity".into()),
756                persistence_location: Some(persisted.clone()),
757                endpoint_capabilities: vec!["http".into(), "acp".into()],
758                supervisor: None,
759            },
760        )
761        .unwrap();
762
763        let added = tokio::time::timeout(Duration::from_secs(2), watch.next())
764            .await
765            .unwrap()
766            .unwrap();
767        assert!(matches!(
768            added,
769            RuntimeRegistryEvent::Added { ref entry }
770                if entry.id == "live-registry-1"
771                    && entry.profile.as_deref() == Some("cc-parity")
772                    && entry.state == RuntimeRegistryState::Idle
773                    && entry.persistence_location.as_ref() == Some(&persisted)
774                    && entry.owner.as_ref().unwrap().pid == std::process::id()
775                    && entry.observers.is_empty()
776                    && !entry.actions.as_ref().unwrap().submit
777        ));
778
779        let observer = registry
780            .attach(
781                "live-registry-1",
782                RuntimeClientId::parse("registry-observer").unwrap(),
783                RuntimeAuthorization::observer(),
784            )
785            .await
786            .unwrap();
787        assert!(!observer.describe().await.unwrap().actions.submit);
788        assert!(matches!(
789            observer.submit("denied".into()).await,
790            Err(SdkError::Unauthorized { ref permission }) if permission == "interact"
791        ));
792        let owner = registry
793            .attach(
794                "live-registry-1",
795                RuntimeClientId::parse("registry-owner").unwrap(),
796                RuntimeAuthorization::owner(),
797            )
798            .await
799            .unwrap();
800        assert_eq!(
801            owner.submit("continue".into()).await.unwrap(),
802            "registry reply"
803        );
804        let listed = registry
805            .list(&query, &RuntimeAuthorization::owner())
806            .await
807            .unwrap();
808        assert_eq!(listed.len(), 1);
809        assert_eq!(listed[0].observers.len(), 2);
810        assert_eq!(
811            listed[0]
812                .owner
813                .as_ref()
814                .and_then(|owner| owner.controller.as_ref())
815                .map(|lease| lease.client_id.as_str()),
816            Some("registry-owner")
817        );
818
819        owner.close().await.unwrap();
820        engine.wait_for_shutdown().await;
821        drop(registration);
822        let removed = tokio::time::timeout(Duration::from_secs(2), async {
823            loop {
824                let event = watch.next().await.unwrap();
825                if matches!(event, RuntimeRegistryEvent::Removed { .. }) {
826                    break event;
827                }
828            }
829        })
830        .await
831        .unwrap();
832        assert_eq!(
833            removed,
834            RuntimeRegistryEvent::Removed {
835                id: "live-registry-1".into()
836            }
837        );
838        assert_eq!(
839            std::fs::read_to_string(&persisted).unwrap(),
840            "SOURCE_BYTES_MUST_SURVIVE\n"
841        );
842        std::env::remove_var("SUPERCODE_HOME");
843        std::fs::remove_dir_all(home).ok();
844    }
845
846    #[test]
847    fn persisted_registry_entries_load_through_the_canonical_catalog() {
848        let root = root("persisted");
849        let workspace = root.join("workspace");
850        let claude = root.join("claude");
851        std::fs::create_dir_all(&workspace).unwrap();
852        std::fs::create_dir_all(&claude).unwrap();
853        let session_path = claude.join("session.jsonl");
854        std::fs::write(
855            &session_path,
856            format!(
857                "{{\"type\":\"user\",\"sessionId\":\"cc-registry\",\"cwd\":{},\"message\":{{\"role\":\"user\",\"content\":\"persisted fact\"}}}}\n",
858                serde_json::to_string(&workspace.to_string_lossy()).unwrap()
859            ),
860        )
861        .unwrap();
862        let empty = root.join("empty");
863        std::fs::create_dir_all(&empty).unwrap();
864        let query = RuntimeRegistryQuery {
865            persisted: DiscoveryQuery {
866                workspace: Some(workspace),
867                harnesses: vec![HarnessId::from(HarnessId::CLAUDE_CODE)],
868                homes: HarnessHomes {
869                    claude_code: claude,
870                    codex: empty.clone(),
871                    pi: empty.clone(),
872                    opencode: empty.clone(),
873                    grok: empty.clone(),
874                    gemini: empty.clone(),
875                    goose: empty.clone(),
876                    supercode: empty.clone(),
877                    openclaw: empty.clone(),
878                    hermes: empty.clone(),
879                    orchestrator: empty,
880                },
881                cursor: None,
882                limit: None,
883                query: None,
884                search_previews: false,
885                include_topic_candidates: false,
886                include_child_sessions: false,
887                root_session_id: None,
888                profile: None,
889                workspace_family: None,
890                updated_after_ms: None,
891                updated_before_ms: None,
892            },
893            include_live: false,
894            include_persisted: true,
895        };
896        let registry = LocalRuntimeRegistry::new();
897        let entries =
898            futures::executor::block_on(registry.list(&query, &RuntimeAuthorization::observer()))
899                .unwrap();
900        assert_eq!(entries.len(), 1);
901        assert_eq!(entries[0].id, "claude-code:cc-registry");
902        assert_eq!(entries[0].state, RuntimeRegistryState::Persisted);
903        assert_eq!(
904            entries[0].persistence_location.as_ref(),
905            Some(&session_path)
906        );
907        let loaded = registry
908            .load_persisted(
909                "claude-code:cc-registry",
910                &query,
911                &RuntimeAuthorization::observer(),
912            )
913            .unwrap();
914        assert_eq!(loaded.messages.len(), 1);
915        assert_eq!(
916            loaded.messages[0].content.as_deref(),
917            Some("persisted fact")
918        );
919        std::fs::remove_dir_all(root).ok();
920    }
921}