Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions crates/sl-viewer/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# sl-viewer — SessionLedger bundle viewer

A Dioxus 0.6 single-codebase viewer for compiled SessionLedger bundles —
A Dioxus 0.7 single-codebase viewer for compiled SessionLedger bundles —
desktop (native) and web (WASM) from one source tree.

## Platform targets
Expand All @@ -21,11 +21,20 @@ The web target requires the `web` feature and the Dioxus CLI (`dx`):
# Install the Dioxus CLI (one time)
cargo install dioxus-cli

# Serve on the web
dx serve --platform web -p sl-viewer
# Terminal 1: start the local daemon with a session watch directory and an output directory.
cd ../sl-daemon
cargo run -- serve --watch ./sessions --out ./okf-out --http-bind 127.0.0.1:8080

# Terminal 2: build and serve the browser viewer against that loopback daemon.
cd ../sl-viewer
SL_DAEMON_URL=http://127.0.0.1:8080 dx serve --platform web --port 8081
```

This compiles `sl-viewer` to WASM and serves it on `http://localhost:8080`.
This compiles `sl-viewer` to WASM and serves it on `http://localhost:8081`.
The **Bundles** screen calls `GET /api/bundles` on `SL_DAEMON_URL` and renders
the daemon's current OKF documents. With the daemon unavailable, it shows a
retryable error instead of embedded demo data. The desktop target remains
local-corpus-first; its native discovery behavior is unchanged.

## Cargo features

Expand Down
117 changes: 88 additions & 29 deletions crates/sl-viewer/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ fn splash_dismiss_delay() -> std::time::Duration {
std::time::Duration::from_millis(1800)
}

/// Monotonic token that lets a discovery task reject an obsolete completion.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct DiscoveryGeneration(u64);

impl DiscoveryGeneration {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(1);
self.0
}

fn is_current(self, request: u64) -> bool {
self.0 == request
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -62,6 +77,16 @@ mod tests {
fn splash_dismiss_delay_matches_the_launch_transition() {
assert_eq!(splash_dismiss_delay(), std::time::Duration::from_millis(1800));
}

#[test]
fn discovery_generation_rejects_obsolete_requests() {
let mut generation = DiscoveryGeneration::default();
let first = generation.next();
let second = generation.next();

assert!(!generation.is_current(first));
assert!(generation.is_current(second));
}
}

impl Tab {
Expand Down Expand Up @@ -226,11 +251,11 @@ pub struct DiscoveryState {
/// 1. `SL_VIEWER_DEMO=1` enables explicit in-memory demo data.
/// 2. `FORGE_DB` loads a Forge SQLite corpus when the sqlite feature is enabled.
/// 3. Default: discover native local session stores.
///
/// The WASM launch path interprets the default as the local daemon API,
/// because a browser cannot read native corpus directories directly.
fn resolve_data_source() -> DataSource {
if std::env::var("SL_VIEWER_DEMO").as_deref() == Ok("1")
|| visual_fixture_active()
|| cfg!(target_arch = "wasm32")
{
if std::env::var("SL_VIEWER_DEMO").as_deref() == Ok("1") || visual_fixture_active() {
return DataSource::Mock;
}
#[cfg(feature = "sqlite")]
Expand Down Expand Up @@ -402,45 +427,79 @@ pub fn App() -> Element {
let mut error_signal: Signal<Option<String>> = use_signal(|| None);
let mut loading_signal: Signal<bool> = use_signal(|| true);
let reload_trigger: Signal<u32> = use_signal(|| 0u32);
let mut discovery_generation = use_signal(DiscoveryGeneration::default);
let custom_paths_signal: Signal<CustomCorpusPath> = use_signal(initial_custom_corpus_paths);
use_context_provider(|| ReloadTrigger(reload_trigger));
use_context_provider(|| CustomCorpusPaths(custom_paths_signal));
use_context_provider(|| DiscoveryState { loading: loading_signal, error: error_signal });
use_effect(move || {
let _ = reload_trigger();
let _ = custom_paths_signal();
let request_generation = discovery_generation.with_mut(DiscoveryGeneration::next);
loading_signal.set(true);
error_signal.set(None);
let source = resolve_data_source();
let custom_snapshot = custom_paths_signal.cloned();
spawn(async move {
let result: std::result::Result<Result<Vec<Session>, String>, String> = {
#[cfg(feature = "desktop")]

#[cfg(all(feature = "web", not(feature = "desktop")))]
{
let source = resolve_data_source();
let custom_snapshot = custom_paths_signal.cloned();
spawn(async move {
let result = if matches!(&source, DataSource::Mock) {
load_sessions_with_custom(&source, &custom_snapshot)
} else {
crate::daemon_source::fetch_daemon_sessions().await
};
if !discovery_generation
.with(|generation| generation.is_current(request_generation))
{
tokio::task::spawn_blocking(move || {
load_sessions_with_custom(&source, &custom_snapshot)
})
.await
.map_err(|error| error.to_string())
return;
}
#[cfg(not(feature = "desktop"))]
{
Ok(load_sessions_with_custom(&source, &custom_snapshot))
loading_signal.set(false);
match result {
Ok(sessions) => sessions_signal.set(sessions),
Err(error) => error_signal.set(Some(error)),
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
loading_signal.set(false);
match result {
Ok(Ok(sessions)) => {
sessions_signal.set(sessions);
}
Ok(Err(e)) => {
error_signal.set(Some(e));
});
Comment thread
KooshaPari marked this conversation as resolved.
}

#[cfg(not(all(feature = "web", not(feature = "desktop"))))]
{
let source = resolve_data_source();
let custom_snapshot = custom_paths_signal.cloned();
spawn(async move {
let result: std::result::Result<Result<Vec<Session>, String>, String> = {
#[cfg(feature = "desktop")]
{
tokio::task::spawn_blocking(move || {
load_sessions_with_custom(&source, &custom_snapshot)
})
.await
.map_err(|error| error.to_string())
}
#[cfg(not(feature = "desktop"))]
{
Ok(load_sessions_with_custom(&source, &custom_snapshot))
}
};
if !discovery_generation
.with(|generation| generation.is_current(request_generation))
{
return;
}
Err(e) => {
error_signal.set(Some(format!("Internal error: {e}")));
loading_signal.set(false);
match result {
Ok(Ok(sessions)) => {
sessions_signal.set(sessions);
}
Ok(Err(e)) => {
error_signal.set(Some(e));
}
Err(e) => {
error_signal.set(Some(format!("Internal error: {e}")));
}
}
}
});
});
}
});
use_context_provider(|| SessionContext(sessions_signal));

Expand Down
17 changes: 17 additions & 0 deletions crates/sl-viewer/src/bundle_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub fn summarize(bundle: &ContinuationBundle) -> BundleSummary {
.find(|b| b.kind == BundleKind::Intent)
.and_then(|b| b.body.get("goal"))
.and_then(|v| v.as_str())
.filter(|goal| !goal.trim().is_empty())
.unwrap_or("(no goal)")
.to_owned();

Expand All @@ -30,3 +31,19 @@ pub fn summarize(bundle: &ContinuationBundle) -> BundleSummary {
has_contract: bundle.has(BundleKind::Contract),
}
}

#[cfg(test)]
mod tests {
use super::*;
use session_ledger::domain::bundle::Bundle;

#[test]
fn blank_intent_goal_uses_nonempty_fallback() {
let bundle = ContinuationBundle {
source_id: "blank-goal".to_owned(),
bundles: vec![Bundle::new(BundleKind::Intent, serde_json::json!({ "goal": "" }))],
};

assert_eq!(summarize(&bundle).intent_goal, "(no goal)");
}
}
146 changes: 146 additions & 0 deletions crates/sl-viewer/src/daemon_source.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
//! Daemon-backed source for web viewer sessions.

use session_ledger::{
domain::session::{Corpus, Message, Role, Session},
validate_okf_document, OkfDocument,
};

use crate::daemon_url::daemon_api_url;

/// Return the existing daemon endpoint used for listing compiled bundles.
pub fn daemon_bundle_url() -> String {
daemon_api_url("/api/bundles")
}

/// Fetch and project every bundle currently exposed by the local daemon.
#[cfg(any(feature = "desktop", feature = "web"))]
pub async fn fetch_daemon_sessions() -> Result<Vec<Session>, String> {
let response = reqwest::Client::new()
.get(daemon_bundle_url())
.send()
.await
.map_err(|error| format!("daemon not reachable: {error}"))?;

if !response.status().is_success() {
return Err(format!("daemon returned {}", response.status()));
}

let body =
response.text().await.map_err(|error| format!("failed to read daemon bundles: {error}"))?;
parse_daemon_bundles(&body)
}

/// Parse the `GET /api/bundles` response into the viewer's shared session model.
pub fn parse_daemon_bundles(body: &str) -> Result<Vec<Session>, String> {
let documents: Vec<serde_json::Value> = serde_json::from_str(body)
.map_err(|error| format!("failed to parse daemon bundles: {error}"))?;

let document_count = documents.len();
let sessions = documents
.into_iter()
.filter_map(|document| serde_json::from_value::<OkfDocument>(document).ok())
.filter_map(|document| session_from_okf(document).ok())
Comment thread
KooshaPari marked this conversation as resolved.
.collect::<Vec<_>>();

if document_count > 0 && sessions.is_empty() {
return Err("failed to parse daemon bundles: no valid sessions".to_owned());
}

Ok(sessions)
}

fn session_from_okf(document: OkfDocument) -> Result<Session, String> {
if !validate_okf_document(&document).is_empty() {
return Err(format!("failed to parse daemon bundles: invalid OKF {}", document.source_id));
}

let corpus = corpus_from_okf(&document.provenance.corpus)?;
let cwd = entity_property(&document, "resource", "cwd");
let title = entity_property(&document, "state", "title");
let messages = document
.entities
.iter()
.filter(|entity| entity.r#type == "intent" && !entity.label.trim().is_empty())
.map(|entity| Message::new(Role::User, entity.label.clone()))
.collect();
Comment thread
KooshaPari marked this conversation as resolved.

Ok(Session { id: document.source_id, corpus, cwd, title, messages })
}

fn entity_property(document: &OkfDocument, entity_type: &str, property: &str) -> Option<String> {
document
.entities
.iter()
.find(|entity| entity.r#type == entity_type)
.and_then(|entity| entity.properties.get(property))
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
}

fn corpus_from_okf(corpus: &str) -> Result<Corpus, String> {
match corpus {
"forge" => Ok(Corpus::Forge),
"codex" => Ok(Corpus::Codex),
"claude-code" => Ok(Corpus::ClaudeCode),
"cursor" => Ok(Corpus::Cursor),
"factory-droid" => Ok(Corpus::FactoryDroid),
"chatgpt-web" => Ok(Corpus::ChatGptWeb),
"claude-web" => Ok(Corpus::ClaudeWeb),
"gemini-web" => Ok(Corpus::GeminiWeb),
other => Err(format!("failed to parse daemon bundles: unsupported corpus {other:?}")),
}
}

#[cfg(test)]
mod tests {
use super::*;
use session_ledger::domain::session::Corpus;

#[test]
fn parses_daemon_documents_into_sessions() {
let sessions = parse_daemon_bundles(
r#"[{"okf":"1.0","source_id":"fuzz-a","entities":[{"id":"intent-0","type":"intent","label":"ship it","properties":null},{"id":"resource-1","type":"resource","label":"working-directory","properties":{"cwd":"/tmp/demo"}}],"provenance":{"corpus":"forge","source_id":"fuzz-a"},"tags":[]}]"#,
)
.expect("valid daemon document");

assert_eq!(sessions[0].id, "fuzz-a");
assert_eq!(sessions[0].corpus, Corpus::Forge);
assert_eq!(sessions[0].cwd.as_deref(), Some("/tmp/demo"));
assert_eq!(sessions[0].messages[0].content, "ship it");
}

#[test]
fn rejects_invalid_okf_response_without_mock_fallback() {
let error = parse_daemon_bundles("not a JSON array")
.expect_err("a non-JSON daemon response is rejected");

assert!(error.contains("failed to parse daemon bundles"));
}

#[test]
fn rejects_nonempty_response_without_valid_sessions() {
let error = parse_daemon_bundles(r#"[{"okf":"2.0"}]"#)
.expect_err("an entirely invalid daemon response must not look empty");

assert!(error.contains("no valid sessions"));
}

#[test]
fn preserves_valid_sessions_when_response_contains_invalid_documents() {
let sessions = parse_daemon_bundles(
r#"[
{"okf":"1.0","source_id":"fuzz-a","entities":[],"provenance":{"corpus":"forge","source_id":"fuzz-a"},"tags":[]},
{"okf":"2.0"}
]"#,
)
.expect("a malformed bundle must not hide valid daemon bundles");

assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "fuzz-a");
}

#[test]
fn daemon_bundle_url_uses_shared_daemon_base() {
assert_eq!(daemon_bundle_url(), "http://127.0.0.1:8080/api/bundles");
}
}
1 change: 1 addition & 0 deletions crates/sl-viewer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub mod corpus_cta;
pub mod corpus_loader;
pub mod corpus_paths;
pub mod corpus_tab;
pub mod daemon_source;
pub mod daemon_url;
pub mod detail_pane;
pub mod fixture;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ proptest! {
.find(|b| b.kind == BundleKind::Intent)
.and_then(|b| b.body.get("goal"))
.and_then(|v| v.as_str())
.filter(|goal| !goal.trim().is_empty())
.unwrap_or("(no goal)");
prop_assert_eq!(summary.intent_goal.as_str(), expected);
prop_assert!(!summary.intent_goal.is_empty());
Expand Down
Loading
Loading