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, 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    /// Attach an authenticated SDK client to one live runtime.
292    pub async fn attach(
293        &self,
294        runtime_id: &str,
295        client_id: RuntimeClientId,
296        authorization: RuntimeAuthorization,
297    ) -> Result<Arc<HttpFrontendRuntime>, SdkError> {
298        require_permission(&authorization, RuntimePermission::Observe)?;
299        let record = find_live_runtime(runtime_id)
300            .map_err(registry_receipt_error)?
301            .ok_or_else(|| SdkError::NotFound {
302                operation: SdkOperation::Resume,
303                message: format!("live runtime `{runtime_id}`"),
304            })?;
305        let resolved = resolve_live_runtime(&record.endpoint, &record.source)
306            .map_err(registry_receipt_error)?;
307        let attached = HttpFrontendRuntime::connect_with_authorization(
308            resolved.base_url,
309            resolved.token,
310            client_id,
311            authorization,
312        )
313        .await?;
314        // A completed attachment is the strongest liveness evidence this
315        // module can have — the receipt just did the job it exists for — so it
316        // ends any outage a passing probe failure had opened.
317        note_reachable(&record.endpoint);
318        Ok(attached)
319    }
320
321    /// Load one persisted descriptor through the same catalog used by list.
322    pub fn load_persisted(
323        &self,
324        id: &str,
325        query: &RuntimeRegistryQuery,
326        authorization: &RuntimeAuthorization,
327    ) -> Result<Session, SdkError> {
328        require_permission(authorization, RuntimePermission::Observe)?;
329        let descriptor = HarnessCatalog::new()
330            .discover(&query.persisted)
331            .map_err(|error| SdkError::Execution {
332                operation: SdkOperation::Discover,
333                message: error.to_string(),
334            })?
335            .into_iter()
336            .find(|descriptor| {
337                format!(
338                    "{}:{}",
339                    descriptor.locator.harness.as_str(),
340                    descriptor.locator.session_id
341                ) == id
342            })
343            .ok_or_else(|| SdkError::NotFound {
344                operation: SdkOperation::Load,
345                message: format!("persisted session `{id}`"),
346            })?;
347        HarnessCatalog::new()
348            .load(&descriptor.locator)
349            .map_err(|error| SdkError::Execution {
350                operation: SdkOperation::Load,
351                message: error.to_string(),
352            })
353    }
354
355    /// Watch joined registry state through a bounded change stream.
356    pub fn watch(
357        &self,
358        query: RuntimeRegistryQuery,
359        authorization: RuntimeAuthorization,
360        poll_interval: Duration,
361    ) -> Result<RuntimeRegistryWatch, SdkError> {
362        require_permission(&authorization, RuntimePermission::Observe)?;
363        let (sender, receiver) = tokio::sync::mpsc::channel(128);
364        let registry = *self;
365        let interval = poll_interval.max(Duration::from_millis(25));
366        let task = tokio::spawn(async move {
367            let mut previous = BTreeMap::<String, RuntimeRegistryEntry>::new();
368            let mut ticker = tokio::time::interval(interval);
369            loop {
370                ticker.tick().await;
371                let current = match registry.list(&query, &authorization).await {
372                    Ok(entries) => entries
373                        .into_iter()
374                        .map(|entry| (entry.id.clone(), entry))
375                        .collect::<BTreeMap<_, _>>(),
376                    Err(error) => {
377                        if sender
378                            .send(RuntimeRegistryEvent::Error {
379                                message: error.to_string(),
380                            })
381                            .await
382                            .is_err()
383                        {
384                            return;
385                        }
386                        continue;
387                    }
388                };
389                for (id, entry) in &current {
390                    let event = match previous.get(id) {
391                        None => Some(RuntimeRegistryEvent::Added {
392                            entry: entry.clone(),
393                        }),
394                        Some(prior) if prior != entry => Some(RuntimeRegistryEvent::Updated {
395                            entry: entry.clone(),
396                        }),
397                        Some(_) => None,
398                    };
399                    if let Some(event) = event {
400                        if sender.send(event).await.is_err() {
401                            return;
402                        }
403                    }
404                }
405                for id in previous.keys().filter(|id| !current.contains_key(*id)) {
406                    if sender
407                        .send(RuntimeRegistryEvent::Removed { id: id.clone() })
408                        .await
409                        .is_err()
410                    {
411                        return;
412                    }
413                }
414                previous = current;
415            }
416        });
417        Ok(RuntimeRegistryWatch { receiver, task })
418    }
419}
420
421/// Failed probes within one outage before a receipt is forgotten.
422const FORGET_AFTER_FAILED_PROBES: u32 = 3;
423/// How long one outage must last before its receipt is forgotten, and — the
424/// same bound, deliberately — how far apart two failures may be and still
425/// belong to the same outage. A gap wider than this is a stretch the runtime
426/// was not observed to be down for, so it ends the outage rather than
427/// extending it.
428const FORGET_AFTER_UNREACHABLE_FOR: Duration = Duration::from_secs(2);
429
430/// Reach one live receipt, and the ONE place a receipt is ever forgotten.
431/// Every registry read — list, describe, watch, and the followed-session
432/// projection — goes through this, so the reaping rule cannot fork.
433///
434/// `Ok(None)` means the runtime did not answer this read: the caller reports
435/// it exactly as it reports a session with no receipt at all, which keeps a
436/// genuinely gone runtime reconciling to `persisted` immediately.
437///
438/// Forgetting is the destructive half, and it is deliberately slower. Nothing
439/// re-announces a runtime — the receipt is written once at registration — so a
440/// forgotten receipt costs the frontend its route to attach for the rest of
441/// that runtime's life. A single failed probe is therefore treated as a
442/// hiccup, not as evidence: a receipt goes only after
443/// `FORGET_AFTER_FAILED_PROBES` failures within ONE outage spanning at least
444/// `FORGET_AFTER_UNREACHABLE_FOR`. Both bounds are needed, because a count
445/// alone means whatever the caller's poll rate makes it mean (4 Hz on a
446/// `harness serve` tick, seconds apart in a watch), and a duration alone would
447/// still act on one unlucky probe.
448///
449/// "One outage" is the load-bearing word, and it is bounded from both ends.
450/// Any successful contact through the receipt ends it — a probe here, and an
451/// [`LocalRuntimeRegistry::attach`], which is the strongest liveness evidence
452/// there is because it is the operation the receipt exists to serve. So does a
453/// gap wider than `FORGET_AFTER_UNREACHABLE_FOR` between two failures, which
454/// is a stretch nothing observed the runtime to be down for. Without both, the
455/// count degenerates into "three unlucky hiccups, however far apart", which
456/// destroys the receipt of a runtime that was up — and serving attaches —
457/// between them.
458///
459/// The tally is per process, so a one-shot read — a single `runtime list` from
460/// the CLI — leaves a stale receipt behind instead of reaping it, and so does
461/// a reader that samples more slowly than the outage window, which can never
462/// see two failures close enough together to corroborate. That is the intended
463/// trade: such a reader still omits the runtime from its output, a receipt
464/// whose owning process is gone is already removed when it is read, and the
465/// file costs nothing until a reader that does sample fast enough — the serve
466/// tick — corroborates the failure and removes it.
467async fn probe_receipt(
468    record: &crate::LiveRuntimeRecord,
469) -> Result<Option<(Arc<HttpFrontendRuntime>, FrontendRuntimeDescriptor)>, SdkError> {
470    let Ok(resolved) = resolve_live_runtime(&record.endpoint, &record.source) else {
471        note_unreachable(&record.endpoint);
472        return Ok(None);
473    };
474    let probe_id = registry_probe_id(&record.endpoint)?;
475    match HttpFrontendRuntime::probe_described(resolved.base_url, resolved.token, probe_id).await {
476        Ok(probed) => {
477            note_reachable(&record.endpoint);
478            Ok(Some(probed))
479        }
480        // A live PID whose loopback endpoint has stopped answering for good is
481        // a stale routing record. Removing it never addresses durable session
482        // or sidecar paths.
483        Err(_) => {
484            note_unreachable(&record.endpoint);
485            Ok(None)
486        }
487    }
488}
489
490/// One in-progress outage: when it started, when it was last confirmed, and
491/// how many probes have failed inside it.
492struct Outage {
493    started: Instant,
494    latest: Instant,
495    failures: u32,
496}
497
498/// Outages in progress, keyed by opaque endpoint. Callers lock it for the
499/// duration of one update and never hold the guard across an await or an
500/// unlink.
501fn outages() -> &'static Mutex<HashMap<String, Outage>> {
502    static OUTAGES: OnceLock<Mutex<HashMap<String, Outage>>> = OnceLock::new();
503    OUTAGES.get_or_init(|| Mutex::new(HashMap::new()))
504}
505
506fn lock_outages() -> MutexGuard<'static, HashMap<String, Outage>> {
507    outages()
508        .lock()
509        .unwrap_or_else(std::sync::PoisonError::into_inner)
510}
511
512/// Record that this endpoint answered. Any successful contact ends whatever
513/// outage was in progress, which is why [`LocalRuntimeRegistry::attach`] calls
514/// this too: a receipt that just served an attachment is demonstrably a good
515/// route, and letting hiccups either side of it accumulate would destroy it.
516fn note_reachable(endpoint: &LiveRuntimeEndpoint) {
517    lock_outages().remove(endpoint.as_str());
518}
519
520/// Record that this endpoint did not answer, and forget its receipt once the
521/// outage is corroborated. This is the ONLY place a receipt is ever forgotten.
522fn note_unreachable(endpoint: &LiveRuntimeEndpoint) {
523    let now = Instant::now();
524    let corroborated = {
525        let mut outages = lock_outages();
526        // A failure further from the previous one than the outage window is
527        // not part of that outage: nothing observed the runtime to be down in
528        // between, and it may well have been serving. Dropping the entry here
529        // is what makes this failure start a fresh outage, and it is also what
530        // bounds the map — an entry outlives its last failure by one window.
531        outages
532            .retain(|_, outage| now.duration_since(outage.latest) <= FORGET_AFTER_UNREACHABLE_FOR);
533        let outage = outages
534            .entry(endpoint.as_str().to_string())
535            .or_insert(Outage {
536                started: now,
537                latest: now,
538                failures: 0,
539            });
540        outage.failures += 1;
541        outage.latest = now;
542        let corroborated = outage.failures >= FORGET_AFTER_FAILED_PROBES
543            && now.duration_since(outage.started) >= FORGET_AFTER_UNREACHABLE_FOR;
544        if corroborated {
545            outages.remove(endpoint.as_str());
546        }
547        corroborated
548    };
549    // Unlink outside the lock: every other endpoint's update would otherwise
550    // queue behind this one's filesystem call.
551    if corroborated {
552        let _ = forget_live_runtime(endpoint);
553    }
554}
555
556/// The one mapping from a live runtime's own report to the registry's
557/// reconciled lifecycle state. Every reader — list, describe, watch, and the
558/// followed-session projection — goes through it.
559fn reconciled_state(descriptor: &FrontendRuntimeDescriptor) -> RuntimeRegistryState {
560    if descriptor.connection_state == FrontendConnectionState::ShuttingDown {
561        RuntimeRegistryState::ShuttingDown
562    } else if descriptor.turn_state == FrontendTurnState::Busy {
563        RuntimeRegistryState::Busy
564    } else {
565        RuntimeRegistryState::Idle
566    }
567}
568
569fn registry_probe_id(endpoint: &LiveRuntimeEndpoint) -> Result<RuntimeClientId, SdkError> {
570    RuntimeClientId::parse(format!(
571        "registry-{}",
572        endpoint.as_str().rsplit('/').next().unwrap_or("probe")
573    ))
574    .map_err(|error| SdkError::InvalidArgument {
575        operation: SdkOperation::Discover,
576        message: error.to_string(),
577    })
578}
579
580fn live_entry(
581    record: crate::LiveRuntimeRecord,
582    descriptor: FrontendRuntimeDescriptor,
583    leases: crate::RuntimeLeaseSnapshot,
584) -> RuntimeRegistryEntry {
585    let state = reconciled_state(&descriptor);
586    RuntimeRegistryEntry {
587        id: record.runtime_session_id.clone(),
588        runtime_id: Some(record.runtime_session_id),
589        source_session_id: record.source.session_id,
590        source_workspace: Some(record.source.workspace),
591        source_harness: record.source.harness,
592        profile: descriptor
593            .emulation_profile
594            .or(record.metadata.profile.clone()),
595        state,
596        model: Some(descriptor.model),
597        owner: Some(RuntimeRegistryOwner {
598            pid: record.pid,
599            controller: leases.controller,
600        }),
601        observers: leases.observers,
602        started_at_ms: Some(record.created_at_ms),
603        updated_at_ms: None,
604        endpoint: Some(record.endpoint),
605        endpoint_capabilities: record.metadata.endpoint_capabilities,
606        actions: Some(descriptor.actions),
607        persistence_location: record.metadata.persistence_location,
608        supervisor: record.metadata.supervisor,
609        title: None,
610    }
611}
612
613fn require_permission(
614    authorization: &RuntimeAuthorization,
615    permission: RuntimePermission,
616) -> Result<(), SdkError> {
617    if authorization.allows(permission) {
618        Ok(())
619    } else {
620        Err(SdkError::Unauthorized {
621            permission: permission.as_str().into(),
622        })
623    }
624}
625
626fn registry_receipt_error(error: crate::LiveRuntimeReceiptError) -> SdkError {
627    SdkError::Execution {
628        operation: SdkOperation::Discover,
629        message: error.to_string(),
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636    use crate::server::{run_http, RpcEngine};
637    use crate::{
638        register_live_runtime_with_metadata, Agent, ChatMessage, ChatRequest, Config, HarnessHomes,
639        HarnessId, LiveRuntimeMetadata, LiveRuntimeSource, Provider, SdkRuntime, Usage,
640    };
641    use async_trait::async_trait;
642
643    struct SaysProvider;
644
645    #[async_trait]
646    impl Provider for SaysProvider {
647        async fn complete(
648            &self,
649            _request: &ChatRequest,
650            _on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
651        ) -> crate::Result<(ChatMessage, Usage)> {
652            Ok((ChatMessage::assistant("registry reply"), Usage::default()))
653        }
654    }
655
656    fn root(label: &str) -> PathBuf {
657        let nonce = std::time::SystemTime::now()
658            .duration_since(std::time::UNIX_EPOCH)
659            .unwrap()
660            .as_nanos();
661        let path = std::env::temp_dir().join(format!(
662            "supercode-runtime-registry-{label}-{}-{}",
663            std::process::id(),
664            nonce
665        ));
666        std::fs::create_dir_all(&path).unwrap();
667        path
668    }
669
670    #[tokio::test]
671    #[allow(clippy::await_holding_lock)]
672    async fn joined_registry_lists_watches_attaches_and_reconciles_without_data_loss() {
673        let _guard = crate::live_runtime::test_environment_lock();
674        let home = root("live");
675        let workspace = home.join("workspace");
676        std::fs::create_dir_all(&workspace).unwrap();
677        let persisted = home.join("canonical.jsonl");
678        std::fs::write(&persisted, "SOURCE_BYTES_MUST_SURVIVE\n").unwrap();
679        std::env::set_var("SUPERCODE_HOME", &home);
680
681        let agent = Agent::with_provider(
682            Config::builder().cwd(workspace.clone()).build(),
683            Box::new(SaysProvider),
684        );
685        let engine = RpcEngine::new_named(agent, "live-registry-1", None);
686        let token: Arc<str> = "registry-owner-token".into();
687        let address = run_http(engine.clone(), "127.0.0.1:0", token.clone())
688            .await
689            .unwrap();
690        let registry = LocalRuntimeRegistry::new();
691        let query = RuntimeRegistryQuery {
692            include_live: true,
693            include_persisted: false,
694            ..RuntimeRegistryQuery::default()
695        };
696        let mut watch = registry
697            .watch(
698                query.clone(),
699                RuntimeAuthorization::observer(),
700                Duration::from_millis(25),
701            )
702            .unwrap();
703        let registration = register_live_runtime_with_metadata(
704            "live-registry-1",
705            LiveRuntimeSource {
706                harness: "claude-code".into(),
707                session_id: "source-1".into(),
708                workspace: workspace.clone(),
709            },
710            format!("http://{address}"),
711            token.to_string(),
712            LiveRuntimeMetadata {
713                profile: Some("cc-parity".into()),
714                persistence_location: Some(persisted.clone()),
715                endpoint_capabilities: vec!["http".into(), "acp".into()],
716                supervisor: None,
717            },
718        )
719        .unwrap();
720
721        let added = tokio::time::timeout(Duration::from_secs(2), watch.next())
722            .await
723            .unwrap()
724            .unwrap();
725        assert!(matches!(
726            added,
727            RuntimeRegistryEvent::Added { ref entry }
728                if entry.id == "live-registry-1"
729                    && entry.profile.as_deref() == Some("cc-parity")
730                    && entry.state == RuntimeRegistryState::Idle
731                    && entry.persistence_location.as_ref() == Some(&persisted)
732                    && entry.owner.as_ref().unwrap().pid == std::process::id()
733                    && entry.observers.is_empty()
734                    && !entry.actions.as_ref().unwrap().submit
735        ));
736
737        let observer = registry
738            .attach(
739                "live-registry-1",
740                RuntimeClientId::parse("registry-observer").unwrap(),
741                RuntimeAuthorization::observer(),
742            )
743            .await
744            .unwrap();
745        assert!(!observer.describe().await.unwrap().actions.submit);
746        assert!(matches!(
747            observer.submit("denied".into()).await,
748            Err(SdkError::Unauthorized { ref permission }) if permission == "interact"
749        ));
750        let owner = registry
751            .attach(
752                "live-registry-1",
753                RuntimeClientId::parse("registry-owner").unwrap(),
754                RuntimeAuthorization::owner(),
755            )
756            .await
757            .unwrap();
758        assert_eq!(
759            owner.submit("continue".into()).await.unwrap(),
760            "registry reply"
761        );
762        let listed = registry
763            .list(&query, &RuntimeAuthorization::owner())
764            .await
765            .unwrap();
766        assert_eq!(listed.len(), 1);
767        assert_eq!(listed[0].observers.len(), 2);
768        assert_eq!(
769            listed[0]
770                .owner
771                .as_ref()
772                .and_then(|owner| owner.controller.as_ref())
773                .map(|lease| lease.client_id.as_str()),
774            Some("registry-owner")
775        );
776
777        owner.close().await.unwrap();
778        engine.wait_for_shutdown().await;
779        drop(registration);
780        let removed = tokio::time::timeout(Duration::from_secs(2), async {
781            loop {
782                let event = watch.next().await.unwrap();
783                if matches!(event, RuntimeRegistryEvent::Removed { .. }) {
784                    break event;
785                }
786            }
787        })
788        .await
789        .unwrap();
790        assert_eq!(
791            removed,
792            RuntimeRegistryEvent::Removed {
793                id: "live-registry-1".into()
794            }
795        );
796        assert_eq!(
797            std::fs::read_to_string(&persisted).unwrap(),
798            "SOURCE_BYTES_MUST_SURVIVE\n"
799        );
800        std::env::remove_var("SUPERCODE_HOME");
801        std::fs::remove_dir_all(home).ok();
802    }
803
804    #[test]
805    fn persisted_registry_entries_load_through_the_canonical_catalog() {
806        let root = root("persisted");
807        let workspace = root.join("workspace");
808        let claude = root.join("claude");
809        std::fs::create_dir_all(&workspace).unwrap();
810        std::fs::create_dir_all(&claude).unwrap();
811        let session_path = claude.join("session.jsonl");
812        std::fs::write(
813            &session_path,
814            format!(
815                "{{\"type\":\"user\",\"sessionId\":\"cc-registry\",\"cwd\":{},\"message\":{{\"role\":\"user\",\"content\":\"persisted fact\"}}}}\n",
816                serde_json::to_string(&workspace.to_string_lossy()).unwrap()
817            ),
818        )
819        .unwrap();
820        let empty = root.join("empty");
821        std::fs::create_dir_all(&empty).unwrap();
822        let query = RuntimeRegistryQuery {
823            persisted: DiscoveryQuery {
824                workspace: Some(workspace),
825                harnesses: vec![HarnessId::from(HarnessId::CLAUDE_CODE)],
826                homes: HarnessHomes {
827                    claude_code: claude,
828                    codex: empty.clone(),
829                    pi: empty.clone(),
830                    opencode: empty.clone(),
831                    grok: empty.clone(),
832                    gemini: empty.clone(),
833                    goose: empty.clone(),
834                    supercode: empty,
835                },
836                cursor: None,
837                limit: None,
838                query: None,
839                include_topic_candidates: false,
840                include_child_sessions: false,
841            },
842            include_live: false,
843            include_persisted: true,
844        };
845        let registry = LocalRuntimeRegistry::new();
846        let entries =
847            futures::executor::block_on(registry.list(&query, &RuntimeAuthorization::observer()))
848                .unwrap();
849        assert_eq!(entries.len(), 1);
850        assert_eq!(entries[0].id, "claude-code:cc-registry");
851        assert_eq!(entries[0].state, RuntimeRegistryState::Persisted);
852        assert_eq!(
853            entries[0].persistence_location.as_ref(),
854            Some(&session_path)
855        );
856        let loaded = registry
857            .load_persisted(
858                "claude-code:cc-registry",
859                &query,
860                &RuntimeAuthorization::observer(),
861            )
862            .unwrap();
863        assert_eq!(loaded.messages.len(), 1);
864        assert_eq!(
865            loaded.messages[0].content.as_deref(),
866            Some("persisted fact")
867        );
868        std::fs::remove_dir_all(root).ok();
869    }
870}