Skip to content
Closed
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
65 changes: 65 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,58 @@ jobs:
with:
fail-on-severity: low

# Fast PR fuzz smoke. Sustained blocking and scheduled lanes live in their
# dedicated workflows; keep this deterministic and bounded at 10 seconds.
fuzz-smoke:
name: fuzz smoke
needs: detect
if: needs.detect.outputs.has_rust == 'true'
runs-on: ubuntu-latest
timeout-minutes: 8
permissions:
contents: read
env:
RUSTFLAGS: ""
RUSTUP_TOOLCHAIN: nightly
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # nightly for fuzz
with:
toolchain: nightly
components: rust-src
targets: x86_64-unknown-linux-gnu
- uses: taiki-e/install-action@e28ac56891501ddb0600608470dbe94544964ed4 # cargo-fuzz
with:
tool: cargo-fuzz
- name: fuzz smoke OKF parse and roundtrip (10s)
run: cargo +nightly fuzz run okf_roundtrip --sanitizer address --target x86_64-unknown-linux-gnu -- -max_total_time=10
- name: fuzz smoke JSONL ingest parse (10s)
run: cargo +nightly fuzz run jsonl_ingest --sanitizer address --target x86_64-unknown-linux-gnu -- -max_total_time=10

# Blocking C04 L40 rootless-only OCI runner matrix scaffold. The full gate
# lives in rootless-matrix.yml; this policy job keeps ci.yml cross-referenced
# and executes the same hermetic SelfCheck.
rootless-matrix-policy:
name: rootless-only matrix policy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: assert rootless-only OCI runner scaffold anchors
shell: pwsh
run: ./scripts/rootless-matrix-check.ps1 -SelfCheck

# Blocking C04 L40 rootless/no-net scaffold. The full gate lives in
# rootless-nonet.yml; this policy job keeps ci.yml cross-referenced and
# executes the same hermetic SelfCheck on every pull request.
rootless-nonet-policy:
name: rootless/no-net policy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: assert rootless/no-net scaffold anchors
shell: pwsh
run: ./scripts/rootless-nonet-check.ps1 -SelfCheck

# Platform signing-readiness policy (C04 L32 / C11 L112). The hard blocking
# gate lives in signing-hard.yml (runs on pull_request); this job runs the
# same SelfCheck here so the signing posture is asserted on every PR and
Expand All @@ -210,6 +262,19 @@ jobs:
shell: pwsh
run: ./scripts/signing-readiness-check.ps1 -SelfCheck

# Eval reproducibility manifest contract (C08 L79). Keep the workflow
# anchor next to the hermetic SelfCheck so the Rust wrapper can verify that
# CI actually runs the same no-network manifest/docs check.
eval-reproducibility:
name: Eval Reproducibility SelfCheck
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: actions/checkout@v7 uses an unpinned tag in the new eval-reproducibility job

The new eval-reproducibility job uses actions/checkout@v7 (unpinned tag), while every other job in this workflow pins to a specific commit SHA (actions/checkout@3d3c42e...). An unpinned tag can silently change behavior when the upstream action is updated, bypassing the repository's pinning policy. Pin this to the same SHA used elsewhere.

Suggested change
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

- name: eval reproducibility SelfCheck
shell: pwsh
run: ./scripts/eval-repro-check.ps1 -SelfCheck

lint:
name: ci / lint
if: always()
Expand Down
254 changes: 254 additions & 0 deletions HANDOFF-session-2026-08-05.md

Large diffs are not rendered by default.

48 changes: 44 additions & 4 deletions crates/sl-daemon/src/etl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,22 @@ fn read_sessions(
Ok(vec![session])
}

/// Make a session id safe to use as a filename (path separators → `_`).
fn sanitize(id: &str) -> String {
id.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '_' } else { c }).collect()
/// Encode a session id as one injective, safe filename component.
///
/// Underscores are escaped as well as path separators so an encoded separator
/// can never collide with an input that already contained the escape marker.
pub(crate) fn sanitize(id: &str) -> String {
let mut encoded = String::with_capacity(id.len());
for character in id.chars() {
match character {
'_' => encoded.push_str("_x5f"),
'/' => encoded.push_str("_x2f"),
'\\' => encoded.push_str("_x5c"),
':' => encoded.push_str("_x3a"),
character => encoded.push(character),
}
}
encoded
}

#[cfg(test)]
Expand Down Expand Up @@ -195,6 +208,32 @@ mod tests {
}
}

#[test]
fn transform_file_keeps_colliding_ids_distinct() {
let tmp = tempfile::tempdir().expect("tempdir");
let jsonl = tmp.path().join("collisions.jsonl");
let sessions = ["a/b", "a_b"];
let mut content = String::new();
for id in sessions {
let mut session = Session::new(id, Corpus::Forge);
session.messages.push(Message::new(Role::User, "keep distinct"));
content.push_str(&serde_json::to_string(&session).expect("serialize session"));
content.push('\n');
}
std::fs::write(&jsonl, content).expect("write fixture");

let written = transform_file(&jsonl, &tmp.path().join("out"), None).expect("transform");

assert_eq!(written.len(), 2);
assert_ne!(written[0], written[1]);
for (path, source_id) in written.iter().zip(sessions) {
let document: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(path).expect("read OKF"))
.expect("parse OKF");
assert_eq!(document["source_id"], source_id);
}
}

#[test]
fn transform_file_creates_missing_out_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
Expand Down Expand Up @@ -227,7 +266,8 @@ mod tests {

#[test]
fn sanitize_replaces_path_separators() {
assert_eq!(sanitize("a/b:c\\d"), "a_b_c_d");
assert_eq!(sanitize("a/b:c\\d"), "a_x2fb_x3ac_x5cd");
assert_eq!(sanitize("a_b"), "a_x5fb");
assert_eq!(sanitize("plain-id"), "plain-id");
}

Expand Down
122 changes: 46 additions & 76 deletions crates/sl-daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,11 +293,11 @@ enum Command {
no_stream: bool,
},

/// Validate an OKF bundle on disk against ingest rules.
/// Validate an OKF bundle on disk against the structural OKF contract.
///
/// Reads `<data_dir>/<bundle_id>.okf.json`, re-packages the metadata as a
/// `PostBundle`, and runs local validation. Exits 0 when valid, 1 when
/// invalid (diagnostics printed to stdout as JSON), 2 on I/O or parse error.
/// Reads `<data_dir>/<bundle_id>.okf.json` and validates its v1 graph,
/// provenance, and relation references. Exits 0 when valid, 1 when invalid
/// (diagnostics printed to stdout as JSON), 2 on I/O or parse error.
#[command(after_help = VALIDATE_AFTER_HELP)]
Validate {
/// Bundle ID (filename stem, without `.okf.json`).
Expand Down Expand Up @@ -1157,85 +1157,33 @@ fn run_restore(bundle_id: &str, data_dir: &Path, out: Option<&Path>) {
// ---------------------------------------------------------------------------

fn run_validate(bundle_id: &str, data_dir: &Path) {
use validation::{PostBundle, PostMessage};

let path = data_dir.join(format!("{bundle_id}.okf.json"));
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => cli::exit_error(format!("cannot read {}: {e}", path.display())),
};

let value: serde_json::Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(e) => cli::exit_error(format!("cannot parse {}: {e}", path.display())),
};

// Re-package the on-disk OKF fields into a PostBundle for validation.
let get_str = |key: &str| {
value
.get(key)
.or_else(|| value.pointer(&format!("/metadata/{key}")))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_owned()
};
let get_i64 = |key: &str| {
value
.get(key)
.or_else(|| value.pointer(&format!("/metadata/{key}")))
.and_then(|v| v.as_i64())
.unwrap_or(0)
};

// Build PostMessages from the OKF entities array (label → content, type → role).
let messages: Vec<PostMessage> = value
.get("entities")
.and_then(|e| e.as_array())
.map(|arr| {
arr.iter()
.map(|ent| {
let role =
ent.get("type").and_then(|v| v.as_str()).unwrap_or("assistant").to_owned();
let content =
ent.get("label").and_then(|v| v.as_str()).unwrap_or_default().to_owned();
PostMessage { role, content }
})
.collect()
})
.unwrap_or_default();

let bundle = PostBundle {
bundle_id: {
let id = get_str("source_id");
if id.is_empty() {
bundle_id.to_owned()
} else {
id
}
},
created_at: {
let ca = get_str("created_at");
// OKF documents may not carry created_at; fall back to a sentinel
// so the validator produces a useful diagnostic rather than silently
// accepting an empty string.
if ca.is_empty() {
String::new()
} else {
ca
}
},
messages,
token_count: get_i64("token_count"),
let errors = match validate_on_disk_okf(bundle_id, data_dir) {
Ok(errors) => errors,
Err(error) => cli::exit_error(error),
};

let result = validation::validate_okf_bundle(&bundle);
let result = serde_json::json!({
"valid": errors.is_empty(),
"errors": errors,
});
let json = serde_json::to_string_pretty(&result).unwrap_or_default();
println!("{json}");
if !result.valid {
if !errors.is_empty() {
std::process::exit(cli::EXIT_NOT_OK);
}
}

fn validate_on_disk_okf(
bundle_id: &str,
data_dir: &Path,
) -> Result<Vec<session_ledger::OkfValidationError>, String> {
let path = data_dir.join(format!("{}.okf.json", crate::etl::sanitize(bundle_id)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The validator sanitizes the CLI argument before resolving the file, so it cannot validate bundles when the documented bundle_id is already the on-disk filename stem, such as a_x5fb for the original session ID a_b. It also disagrees with other daemon paths that use the raw bundle ID. Resolve the same filename representation used by the caller and writer, or explicitly normalize only source IDs rather than filename stems. [api mismatch]

Severity Level: Major ⚠️
- ❌ Validate rejects encoded bundle filenames generated by ETL.
- ⚠️ CLI automation receives a missing-file error for valid bundles.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** crates/sl-daemon/src/main.rs
**Line:** 1179:1179
**Comment:**
	*Api Mismatch: The validator sanitizes the CLI argument before resolving the file, so it cannot validate bundles when the documented `bundle_id` is already the on-disk filename stem, such as `a_x5fb` for the original session ID `a_b`. It also disagrees with other daemon paths that use the raw bundle ID. Resolve the same filename representation used by the caller and writer, or explicitly normalize only source IDs rather than filename stems.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

let text = std::fs::read_to_string(&path)
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
let document: session_ledger::OkfDocument = serde_json::from_str(&text)
.map_err(|error| format!("cannot parse {}: {error}", path.display()))?;
Ok(session_ledger::validate_okf_document(&document))
}

// ---------------------------------------------------------------------------
// search
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1409,6 +1357,28 @@ async fn run_replay(base_url: &str, bundle_id: &str, speed: f64, no_stream: bool
mod tests {
use super::*;

#[test]
fn validate_on_disk_okf_accepts_daemon_generated_document() {
let tmp = tempfile::tempdir().expect("tempdir");
let watch = tmp.path().join("watch");
let out = tmp.path().join("out");
std::fs::create_dir_all(&watch).expect("create watch directory");

let mut session =
session_ledger::Session::new("nested/session", session_ledger::Corpus::Forge);
session.messages.push(session_ledger::Message::new(session_ledger::Role::User, "ship it"));
let transcript = serde_json::to_string(&session).expect("serialize session");
std::fs::write(watch.join("session.jsonl"), format!("{transcript}\n"))
.expect("write transcript");

let written = crate::etl::transform_file(&watch.join("session.jsonl"), &out, None)
.expect("daemon ETL should export OKF");
assert_eq!(written.len(), 1);
assert!(validate_on_disk_okf("nested/session", &out)
.expect("validate daemon output")
.is_empty());
}

#[test]
fn format_timestamp_zero() {
assert_eq!(format_timestamp(0), "00:00:00");
Expand Down
48 changes: 45 additions & 3 deletions crates/sl-viewer/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ impl Tab {
Self::ALL.iter().position(|&t| t == self).unwrap_or(0)
}

/// Return the SVG icon name for this tab.
fn icon(&self) -> &'static str {
match self {
Self::Memory => "memory",
Self::Bundles => "bundles",
Self::History => "history",
Self::Unfinished => "unfinished",
Self::LiveFeed => "live",
Self::Timeline => "timeline",
Self::Search => "search",
Self::Replay => "replay",
}
}

fn from_index(i: usize) -> Tab {
Self::ALL[i % Self::ALL.len()]
}
Expand Down Expand Up @@ -190,6 +204,31 @@ fn build_bundles_from_sessions(sessions: &[Session]) -> Vec<ContinuationBundle>

// `App` is a Dioxus component (mounted by name from main.rs / web entry).
#[allow(non_snake_case)]
/// Inline SVG icons for each tab.
const ICON_SVG_BUNDLES: &str = include_str!("../../../assets/icons/line/bundles.svg");
const ICON_SVG_HISTORY: &str = include_str!("../../../assets/icons/line/history.svg");
const ICON_SVG_MEMORY: &str = include_str!("../../../assets/icons/line/memory.svg");
const ICON_SVG_UNFINISHED: &str = include_str!("../../../assets/icons/line/unfinished.svg");
const ICON_SVG_TIMELINE: &str = include_str!("../../../assets/icons/line/timeline.svg");
const ICON_SVG_LIVE: &str = include_str!("../../../assets/icons/line/live.svg");
const ICON_SVG_SEARCH: &str = include_str!("../../../assets/icons/line/search.svg");
const ICON_SVG_REPLAY: &str = include_str!("../../../assets/icons/line/replay.svg");

/// Lookup table for tab icon SVGs.
fn icon_svg(tab_icon: &str) -> &'static str {
match tab_icon {
"bundles" => ICON_SVG_BUNDLES,
"history" => ICON_SVG_HISTORY,
"memory" => ICON_SVG_MEMORY,
"unfinished" => ICON_SVG_UNFINISHED,
"timeline" => ICON_SVG_TIMELINE,
"live" => ICON_SVG_LIVE,
"search" => ICON_SVG_SEARCH,
"replay" => ICON_SVG_REPLAY,
_ => ICON_SVG_BUNDLES,
}
}
#[allow(non_snake_case)]
pub fn App() -> Element {
#[cfg(feature = "web")]
use_effect(|| {
Expand Down Expand Up @@ -396,7 +435,7 @@ pub fn App() -> Element {
Tab::Timeline => {
let bundles = build_bundles_from_sessions(&sessions_signal.read());
rsx! { TimelineView { bundles } }
},
}
Tab::Replay => rsx! { ReplayView {} },
};

Expand Down Expand Up @@ -865,7 +904,10 @@ pub fn App() -> Element {
_ => {}
}
},
"{tab.label()}"
span {
dangerous_inner_html: "{icon_svg(tab.icon())}"
}
"{tab.label()}"
}
}
}
Expand Down Expand Up @@ -971,7 +1013,7 @@ fn BundlesTab() -> Element {
let _ = load_gen();
loading.set(true);
load_error.set(None);
let loaded = build_bundles_from_sessions(&*ctx.0.read());
let loaded = build_bundles_from_sessions(&ctx.0.read());
if loaded.is_empty() {
load_error.set(Some("No bundles available to display.".into()));
} else {
Expand Down
1 change: 0 additions & 1 deletion crates/sl-viewer/src/corpus_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
use session_ledger::domain::session::Session;

use crate::mock_data::sample_sessions;
use crate::web_exports::*;

/// Source configuration for the viewer's session list.
#[derive(Debug, Clone, Default)]
Expand Down
Loading
Loading