diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70a99290..8b3adb5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 + - name: eval reproducibility SelfCheck + shell: pwsh + run: ./scripts/eval-repro-check.ps1 -SelfCheck + lint: name: ci / lint if: always() diff --git a/HANDOFF-session-2026-08-05.md b/HANDOFF-session-2026-08-05.md new file mode 100644 index 00000000..4eced7bf --- /dev/null +++ b/HANDOFF-session-2026-08-05.md @@ -0,0 +1,254 @@ +# Session Handoff — SessionLedger / Grapheon / asset-engine +**Captured:** 2026-08-05 (final state of the Forge session the user is closing due to length + hallucination drift). +**Verifier:** Droid (independent re-verification against live repos). +**Audience:** the next session / agent that picks up this work. + +--- + +## 0. Why this handoff exists + +The user is rotating off this Forge session because it grew too long and the prior agent's last summary contained minor hallucinations (wrong SHA, wrong PR count, wrong Spotlight count). This document is the canonical ground truth **as I verified it on 2026-08-05 against the actual filesystem + git + `gh`**. + +Use this as the source of record. Do not trust the in-conversation summary alone. + +--- + +## 1. The user's intent — three prompts drove the entire session + +| # | Prompt (paraphrased) | Underlying intent | +|---|----------------------|-------------------| +| 1 | "Get the newest build installed to my Applications folder after all branches merged/PR'd local and remote, finishing unfinished work." | Close out wip branches, merge what's mergeable, rebuild, install, ship. | +| 2 | "Do all + don't forget GFX/asset engine work for your respective owned repo." | After PR cleanup, continue into brand assets, icons, splash, packaging polish. | +| 3 | "Walk me through the entire session + all prompts + plans + remaining work so I can send it to a new session and delete you." | **This prompt.** Produce a faithful, self-contained handoff. | + +The session's task surface spanned **3 owned repos**: `SessionLedger`, `Grapheon`, `asset-engine`. All three were touched. + +--- + +## 2. Verified ground truth (Droid, 2026-08-05) + +### 2.1 `/Applications/SessionLedger.app` + +| Field | Value | +|-------|-------| +| Installed binary SHA-256 | `8eebe1f6706997b89c606630978eb41a49ee95005c53cb4484e2f180ab6cd6b3` | +| `AppIcon.icns` | present, 174K, brand mascot | +| Window | opens (~1s after daemon warm) | +| Mock leaks in `app.rs` | 0 (`sample_bundles` / `sample_sessions` / `mock_data` count = 0) | + +> **Discrepancy vs prior summary:** prior summary claimed SHA `9642096b…`. Actual is `8eebe1f6…`. Three `wip: auto-commit daemon` commits landed after that summary. The current binary reflects `6ae127f4`, not the prior `2c712cb7`. + +### 2.2 `SessionLedger` git + +| Field | Value | +|-------|-------| +| HEAD | `6ae127f4 wip: auto-commit daemon 2026-08-05T23:34:53Z` | +| Branch | `main` | +| Ahead of `origin/main` by | 3 commits (`6ae127f4`, `61d517b7`, `bf4c8e7d`) | +| Most recent merge | `#412 fix/release-blockers-20260802` (WiX MSI GUID fix) | +| Open PRs | 0 | +| Local branches still present | **many** — see §2.4 | + +### 2.3 `Grapheon` git + +| Field | Value | +|-------|-------| +| HEAD | `31642fb2a chore(deps): bump gitpython, ray, setuptools, cryptography, aiohttp past dependabot patches` | +| Branch | `airlock-recovery/wip/2026-07-15-recovered-545e737-main` | +| Open PRs | **1** — `#5 ci: publish recovered default required gates` | + +> **Discrepancy vs prior summary:** prior summary claimed "0 open PRs". Grapheon PR #5 is **still open**. It must be reviewed/merged/closed before the next session is considered clean. + +### 2.4 `asset-engine` git + +| Field | Value | +|-------|-------| +| HEAD | `41ade55 archive: tombstone — asset-engine absorbed back to phenoDesign` | +| Branch | `preserve/asset-engine-archive-20260729` | +| Status | archived + tombstoned, description redirected to `phenoDesign` | +| Open PRs | 0 | + +### 2.5 Local SessionLedger branches that survived this session + +`feat/preserve-viewer-eval-contract`, `feat/session-recovery-integration`, `feat/sessionledger-async-packaging-restored-20260802`, `feat/sl-w44-reaudit`, `feat/viewer-discovery-perf-baseline`, `fix/ci-eval-repro-selfcheck-20260805`, `fix/corpus-loader-ingestion-promotion`, `fix/corpus-loader-ingestion-promotion-retry`, `fix/daemon-sse-registry`, `fix/packaging-version-regex`, `fix/replay-breadth-rustfmt`, `fix/sessionledger-assets-bundle-reconcile-20260803`, `fix/sessionledger-bare-release-assets`, `fix/sessionledger-bundle-order-20260802`, `fix/sessionledger-bundle-recovery-promote-20260805`, `fix/sessionledger-conflict-markers-20260802`, `fix/sessionledger-conflict-tail-20260802`, `fix/sessionledger-etl-adapters`, `fix/sessionledger-forward-candidate`, `fix/sessionledger-fresh-bundle-20260803t2329z`, `fix/sessionledger-pr391-trunk`, `fix/sessionledger-production-readiness`, `fix/sessionledger-production-release-20260802`, `fix/sessionledger-self-validate-20260805`, `fix/viewer-lane-c-detail-replay`, plus 5 `wip/-` snapshot branches. + +> **Discrepancy vs prior summary:** the prior summary claimed branches had been cleaned. They were not — most are still local. The user said "default: preserve" so this is intentional, but the next session should not assume branches are gone. + +### 2.6 Spotlight count (caveat) + +`mdfind "kMDItemKind==Application && kMDItemDisplayName==SessionLedger*"` returned `0` during this verification — this can be a Spotlight reindex lag, not proof of the prior claim "exactly 1 entry". The next session should `mdimport /Applications/SessionLedger.app` and re-run, or simply trust the `lsregister -f` registration step that was performed. + +--- + +## 3. What the previous session actually did (compressed timeline) + +### Phase 1 — Initial merge-close + install +- Surveyed ~40 wip branches + 10 PRs across SessionLedger. +- Cherry-picked/closed: #321, #367, #393, #330. +- Merged: #365 (recovery), #368 (W44-B6 corpus), #373 (PERT correction), #414 (release blockers). +- Rebuilt `sl-viewer --release`, packaged, installed → `/Applications/SessionLedger.app`. + +### Phase 2 — Grapheon + asset-engine +- **Grapheon:** surveyed branches; `feat/tracera-persistent-trace-repository` (597 commits, no shared ancestry with `airlock-recovery` because it wholesale-renamed a separate Tracera repo). Cherry-picked only the canonical lockfile commit `a1e22449a`. Wrote `HANDOFF-tracera-merge.md` with merge strategies. Reset merge branch to safe state. Tag: pre-tracera-merge safety. +- **asset-engine:** GitHub-archived with tombstone README, description redirected to `phenoDesign`. Zero branches left on the live repo. + +### Phase 3 — App window fix (root cause: synchronous corpus load) +- `corpus_loader::load_sessions(&source)` ran synchronously inside `App()`, blocking the Dioxus render thread for 12+ seconds scanning 21,567 files (`stat`/`readdir`). +- Fix: rewrote `app.rs:185-220` to use `use_signal` + `use_effect` + `tokio::task::spawn_blocking`. `SessionContext` now wraps `Signal>`. Updated 3 consumer files: `history_tab.rs`, `unfinished_tab.rs`, `session_transcript.rs` to deref `.read()`. + +### Phase 4 — Icon + Spotlight + font polish +- Removed 6 stale `/Applications/SessionLedger*` entries (backup copies). +- Removed cargo-dx staging `SlViewer.app`. +- Unregistered `packaging/dist/SessionLedger.app` from LaunchServices. +- Generated proper `.icns` from `assets/icons/sessionledger.iconset/` via `iconutil -c icns`. +- Updated `package-app.sh:33-46` to copy `AppIcon.icns` into `Contents/Resources/` and inject `CFBundleIconFile` + `CFBundleIconName` into `Info.plist`. +- Updated `install-local.sh` to only archive to `.previous` on first install. +- Added `NSPrincipalClass=NSApplication` to `Info.plist`. +- Re-registered with `lsregister -f`. + +### Phase 5 — Real data wired (eliminate mock) +- Three `sample_bundles()` calls found and removed from `app.rs` (lines 18, 352, 923 in the older revision). +- Replaced with `build_bundles_from_sessions(&sessions_signal.read())` deriving real `Vec` from `SessionContext`. +- Built + ran `sl-daemon` on `127.0.0.1:8080`. +- Registered LaunchAgent for daemon auto-start. +- Bundles derive `BundleKind::Context/Intent/Worklog/Contract/Provenance` from `Session::{id, title, corpus, messages::{role, content}}`. +- Result: 0 mock leaks in `app.rs` (verified). + +### Phase 6 — Brand asset suite (53 SVGs + build pipeline) +| Category | Files | Content | +|----------|-------|---------| +| Mascot | 5 | `getta-base/listening/happy/thinking/animated.svg` — rigged poses with `` for state swap | +| 2.5D icons | 13 | 8 tabs + 5 status (check, x, alert, live-dot, loading) — depth-filled, Lab-Coat palette | +| Line icons | 13 | Monoline for dense UI | +| Panels | 6 | `card-bg`, 4 corners, divider | +| Brand | 8 | hero (1200x630, lc-chip), og-card, twitter/mobile cards, dock tile, dividers | +| Build script | 1 | `scripts/build_brand_assets.sh` — `magick` SVG→PNG, `iconutil` PNG→icns with @2x | +| Installed | 1 | `/Applications/SessionLedger.app/Contents/Resources/AppIcon.icns` (174K) | + +### Phase 7 — `web_exports` module restoration + UI crashes +- Prior merge deleted `crates/sl-viewer/src/web_exports.rs` but left `mod web_exports;` declaration gone and `use web_exports::*;` remaining in `corpus_loader.rs` (test code) and call sites in production code → `cargo build` broke. +- Fix: recreated `web_exports.rs` with `WebExportProvider::{ChatGpt, Claude, Gemini}`, `web_export_roots_with_env()`, `load_web_export_corpus()`. Re-added `pub mod web_exports;` to `lib.rs`. Fixed `.cloned().collect()` on `Option<&str>` in SVG parts extraction. Resolved `<<<<<<<` conflict markers in `corpus_loader.rs`. +- Sidebar overflow fix: tab buttons previously rendered in a 3×2 horizontal grid; patched to stack vertically. + +### Phase 8 — Tab icons wired into the viewer +- Added `Tab::icon()` method on the `Tab` enum. +- Added 8 `ICON_SVG_*` constants using `include_str!("../../../assets/icons/line/.svg")`. +- Added `icon_svg(tab_icon: &str) -> &'static str` lookup helper. +- Added `dangerous_inner_html: "{icon_svg(tab.icon())}"` to the tab button `span`. +- Build clean; SVGs embedded in binary. + +### Phase 9 — Grapheon dependabot cleanup +- 18 open alerts resolved in one `uv lock` against a clean `[tool.uv] override-dependencies` block: + +| Package | Before | After | CVE | +|---------|--------|-------|-----| +| gitpython | 3.1.50 | 3.1.58 | RCE in `ArgumentParser` | +| ray | 2.55.1 | 2.56.1 | CVE-2026-57516 RCE | +| setuptools | <82.0.0 | 83.0.0 | CVE-2026-59890 macOS RCE | +| cryptography | (vulnerable) | 50.0.0 | — | +| aiohttp | (vulnerable) | 3.14.3 | request smuggling | + +- Pushed to `origin/airlock-recovery/wip/2026-07-15-recovered-545e737-main`. +- PR #3 merged; PR #4 closed stale/BLOCKED. **PR #5 still open** (caught in this handoff). + +--- + +## 4. Where the app still falls short (user-visible gaps) + +These are the issues the user raised in their last interaction that remain open. Pick the first one up when the next session starts. + +### 4.1 Sidebar layout +- Tab buttons render vertically (`tab-bar` is a `flex-direction: column` container — verified). +- However, the user reported a **3×2 horizontal grid** originally. If the live installed binary still shows the old layout, the rebuild/install step is stale. Verify by re-launching `/Applications/SessionLedger.app` and visually confirming. + +### 4.2 Empty data on every page +Three root causes were identified; only one was fully fixed: +1. **Daemon port mismatch** — viewer defaults to `8732`, daemon runs on `8080`. The `daemon_url` module should resolve this; verify before assuming data should appear. +2. **Parquet files (`~/.claude/projects/*.parquet`)** — `JsonCorpusSource` only parses `.jsonl`. If Claude sessions are in parquet, they won't load. Need a `ParquetCorpusSource` adapter. +3. **BundlesTab empty-state banner** — `loaded.is_empty()` shows "No bundles" even when real sessions exist. `build_bundles_from_sessions` was wired but the empty-state predicate needs rechecking. + +### 4.3 No raw session discovery page +- Not started. The current 8 tabs (Bundles, History, Unfinished, Memory, LiveFeed, Search, Timeline, Replay) are all derived. There is no tab that exposes the underlying `Session` records before they are bundled. Likely needs a `Tab::RawSessions` (or `Tab::Corpus`) addition. + +### 4.4 Splash screen +- `splash_hold_fixture_active()` only triggers under fixture GA, not the live app path. +- The launch splash markup is plain text (`SessionLedger` / `Viewer`) — no logo, no spinner, no skeleton. +- Brand mascot exists (`assets/icons/2.5d/getta-*.svg`) but is not wired into the splash. + +### 4.5 In-app experience gaps +- No menu bar (system menu bar) wiring for app-level controls. +- No settings page. +- No sub-pages / per-page additional panels. +- No "feed data" affordance — user cannot point the app at a custom corpus directory from the UI. + +### 4.6 Design tokens +- `tokens.css` has `--sl-color*`, `--pheno-*`, `--lc-*` brand colors. +- No sidebar/nav/layout tokens (width, min/max, padding, focus ring, active indicator). +- Recommend adding: `--sl-sidebar-width`, `--sl-sidebar-pad`, `--sl-tab-active-bg`, `--sl-tab-focus-ring`. + +### 4.7 README polish +- Not started. + +### 4.8 `docs/ops/WBS.md` +- 2 remaining checkpoints (36→40 and 38→40) await human sign-off. + +--- + +## 5. W44 human-gated lanes (unchanged) + +| Lane | Status | Gate | +|------|--------|------| +| R-2 (W44-B2 Windows allocator prod) | PR #375 merged (readiness gate + runbook) | Actual rollout window + SRE sign-off | +| R-3 (W44-B3 brew/winget/signing) | PR #376 merged (template + env-var entry point) | Actual signing cert issuance | +| R-4 (W44-B4 KMS vs PII policy) | PR #376 merged (L22 → KMS stub, docs) | Human policy sign-off | + +--- + +## 6. Grapheon tracera persistence + +- `feat/tracera-persistent-trace-repository` (597 commits) has no shared ancestry with `airlock-recovery` because it wholesale-renames a separate Tracera repo. +- `HANDOFF-tracera-merge.md` documents strategy 4a. +- Correct path is **not** a full-branch merge — needs substrait cherry-picks of the persistence work (3-5 commits). + +--- + +## 7. Suggested next-lane priorities (informational, not authoritative) + +If the user asks "what's next?", pick from this list: + +1. **Fix sidebar visual overflow on the live binary** — confirm the rebuild actually contains the vertical-stack fix. Reinstall. +2. **Resolve daemon port mismatch** — `daemon_url` module probably needs to default to 8080 or read from env. +3. **Add `ParquetCorpusSource`** so Claude parquet sessions appear. +4. **Add a Raw Sessions tab** (`Tab::Corpus`) showing the underlying `Vec`. +5. **Wire the mascot into the launch splash** (replace the plain-text `SessionLedger` / `Viewer` span). +6. **Add design tokens for sidebar/nav** and update `tokens.css` L107 scorecard entry. +7. **Review/merge/close Grapheon PR #5** so the dep-cleanup claim is finally complete. +8. **README + WBS finalization** (low risk). + +--- + +## 8. Caveats + verification gaps + +- **Spotlight indexing** — the prior claim of "exactly 1 entry" was not re-confirmed; the verifier query returned 0 (likely a Spotlight reindex lag). The next session should `mdimport` and re-check rather than re-run the broken query. +- **Daemon liveness** — `pgrep -lf sl-daemon` was not run in this verification; assume the LaunchAgent is doing its job and verify at session start. +- **Bundle emptiness** — the empty-state predicate fix was described but not re-verified in the live binary. The next session should open the Bundles tab and confirm whether real data appears. +- **Brand asset parity** — `assets/icons/2.5d/` and `assets/icons/line/` exist; whether `assets/icons/sessionledger.iconset/` contains the expected PNGs was not re-checked. If `iconutil` fails on rebuild, that's the first place to look. + +--- + +## 9. Files / artifacts the next session may need + +- `SessionLedger/HANDOFF-tracera-merge.md` — Grapheon tracera strategies. +- `SessionLedger/scripts/build_brand_assets.sh` — brand SVG→PNG→icns pipeline. +- `SessionLedger/package-app.sh` — Info.plist + AppIcon.icns injection. +- `SessionLedger/install-local.sh` — installs binary to `/Applications/`. +- `SessionLedger/crates/sl-viewer/src/app.rs` — Tab enum, ICON_SVG_*, dangerous_inner_html wire-up at line ~907. +- `SessionLedger/crates/sl-viewer/src/web_exports.rs` — ChatGPT/Claude/Gemini web export discovery. +- `SessionLedger/crates/sl-viewer/src/corpus_loader.rs` — `DataSource::Auto`, `load_discovered_sessions()`, JSON corpus loader. +- `SessionLedger/assets/tokens.css` — Lab-Coat palette + type scale. + +--- + +## 10. Stop signal + +This handoff is complete. The user's session is being closed due to length + hallucination concerns in the prior Forge conversation. The new session should begin from §1 of this file and pick a priority from §7. diff --git a/crates/sl-daemon/src/etl.rs b/crates/sl-daemon/src/etl.rs index 7d27530b..a128da7f 100644 --- a/crates/sl-daemon/src/etl.rs +++ b/crates/sl-daemon/src/etl.rs @@ -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)] @@ -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"); @@ -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"); } diff --git a/crates/sl-daemon/src/main.rs b/crates/sl-daemon/src/main.rs index 93a7ca6b..f10b9a14 100644 --- a/crates/sl-daemon/src/main.rs +++ b/crates/sl-daemon/src/main.rs @@ -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 `/.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 `/.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`). @@ -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 = 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, String> { + let path = data_dir.join(format!("{}.okf.json", crate::etl::sanitize(bundle_id))); + 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 // --------------------------------------------------------------------------- @@ -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"); diff --git a/crates/sl-viewer/src/app.rs b/crates/sl-viewer/src/app.rs index aefae98a..7a622e79 100644 --- a/crates/sl-viewer/src/app.rs +++ b/crates/sl-viewer/src/app.rs @@ -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()] } @@ -190,6 +204,30 @@ fn build_bundles_from_sessions(sessions: &[Session]) -> Vec // `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, + } +} pub fn App() -> Element { #[cfg(feature = "web")] use_effect(|| { @@ -396,7 +434,7 @@ pub fn App() -> Element { Tab::Timeline => { let bundles = build_bundles_from_sessions(&sessions_signal.read()); rsx! { TimelineView { bundles } } - }, + } Tab::Replay => rsx! { ReplayView {} }, }; @@ -865,7 +903,10 @@ pub fn App() -> Element { _ => {} } }, - "{tab.label()}" + span { + dangerous_inner_html: "{icon_svg(tab.icon())}" + } + "{tab.label()}" } } } diff --git a/crates/sl-viewer/src/web_exports.rs b/crates/sl-viewer/src/web_exports.rs index b3f50642..507f9840 100644 --- a/crates/sl-viewer/src/web_exports.rs +++ b/crates/sl-viewer/src/web_exports.rs @@ -78,10 +78,7 @@ pub fn web_export_roots_with_env( explicit_list .into_iter() .map(|p| { - let provider = match p - .file_name() - .and_then(|s| s.to_str()) - { + let provider = match p.file_name().and_then(|s| s.to_str()) { Some("ChatGPT") | Some("chatgpt") => WebExportProvider::ChatGpt, Some("Claude") | Some("claude") => WebExportProvider::Claude, _ => WebExportProvider::Gemini, @@ -90,10 +87,7 @@ pub fn web_export_roots_with_env( }) .collect() } else { - defaults - .into_iter() - .filter(|(_, p)| p.exists()) - .collect() + defaults.into_iter().filter(|(_, p)| p.exists()).collect() } } @@ -109,12 +103,8 @@ pub fn load_web_export_corpus( let label = provider.label(); let mut loaded = 0usize; - let entries = fs::read_dir(path).map_err(|e| { - format!( - "could not read {label} export root {}: {e}", - path.display() - ) - })?; + let entries = fs::read_dir(path) + .map_err(|e| format!("could not read {label} export root {}: {e}", path.display()))?; for entry in entries.flatten() { let entry_path = entry.path(); @@ -125,10 +115,7 @@ pub fn load_web_export_corpus( } // only attempt files we recognise as JSON exports - let ext = entry_path - .extension() - .and_then(|s| s.to_str()) - .unwrap_or_default(); + let ext = entry_path.extension().and_then(|s| s.to_str()).unwrap_or_default(); if !matches!(ext, "json") { continue; } @@ -190,71 +177,49 @@ fn parse_web_export(raw: &str, corpus: Corpus) -> Option { format!("{:x}", h.finish()) }); - let messages: Vec = if let Some(arr) = - value.get("messages").and_then(|m| m.as_array()) - { - arr.iter() - .filter_map(|m| { - let role = m - .get("role") - .and_then(|v| v.as_str()) - .and_then(parse_role)?; - let content = m - .get("content") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_owned(); - let ts_ms = m.get("ts_ms").and_then(|v| v.as_i64()); - Some(session_ledger::domain::session::Message { - role, - content, - ts_ms, + let messages: Vec = + if let Some(arr) = value.get("messages").and_then(|m| m.as_array()) { + arr.iter() + .filter_map(|m| { + let role = m.get("role").and_then(|v| v.as_str()).and_then(parse_role)?; + let content = + m.get("content").and_then(|v| v.as_str()).unwrap_or_default().to_owned(); + let ts_ms = m.get("ts_ms").and_then(|v| v.as_i64()); + Some(session_ledger::domain::session::Message { role, content, ts_ms }) }) - }) - .collect() - } else if let Some(map) = value - .get("mapping") - .and_then(|m| m.as_object()) - { - map.values() - .filter_map(|node| { - let msg = node.get("message")?; - let role = msg - .get("author") - .and_then(|a| a.get("role")) - .and_then(|v| v.as_str()) - .and_then(parse_role)?; - let content = msg - .get("content") - .map(|c| match c { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Array(parts) => parts - .iter() - .filter_map(|p| p.get("text").and_then(|v| v.as_str()).map(String::from)) - .collect::>() - .join("\n"), - _ => String::new(), - }) - .unwrap_or_default(); - let ts_ms = msg.get("create_time").and_then(|v| v.as_f64().map(|f| f as i64)); - Some(session_ledger::domain::session::Message { - role, - content, - ts_ms, + .collect() + } else if let Some(map) = value.get("mapping").and_then(|m| m.as_object()) { + map.values() + .filter_map(|node| { + let msg = node.get("message")?; + let role = msg + .get("author") + .and_then(|a| a.get("role")) + .and_then(|v| v.as_str()) + .and_then(parse_role)?; + let content = msg + .get("content") + .map(|c| match c { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Array(parts) => parts + .iter() + .filter_map(|p| { + p.get("text").and_then(|v| v.as_str()).map(String::from) + }) + .collect::>() + .join("\n"), + _ => String::new(), + }) + .unwrap_or_default(); + let ts_ms = msg.get("create_time").and_then(|v| v.as_f64().map(|f| f as i64)); + Some(session_ledger::domain::session::Message { role, content, ts_ms }) }) - }) - .collect() - } else { - Vec::new() - }; + .collect() + } else { + Vec::new() + }; - Some(Session { - id, - corpus, - cwd: None, - title, - messages, - }) + Some(Session { id, corpus, cwd: None, title, messages }) } fn parse_role(s: &str) -> Option { diff --git a/docs/ops/eval-manifest.json b/docs/ops/eval-manifest.json index 533c6618..d29f1cf9 100644 --- a/docs/ops/eval-manifest.json +++ b/docs/ops/eval-manifest.json @@ -44,7 +44,7 @@ ], "rust_msrv": "1.85", "toolchain_channel": "stable", - "cargo_lock_sha256": "4176b887c54d8405a68b5a86b122a29d95f738990156865f01cae8300e7d3f49", + "cargo_lock_sha256": "51c723aedcc67d0e5db4a5705ed08d4bedf2c5a3ad226b5fe308cdf5b163f206", "bench_policy_path": "docs/ops/perf-baseline.json", "bench_gate_script": "scripts/bench-gate.ps1", "verify_command": "./scripts/eval-repro-check.ps1" diff --git a/src/lib.rs b/src/lib.rs index 28080723..24603b36 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,7 +62,10 @@ pub use ports::adapters::ZstdCompressor; pub use ports::adapters::{ InMemoryMemoryStore, NoopTraceSink, PassthroughCompressor, TracingTraceSink, }; -pub use ports::okf::{OkfDocument, OkfEntity, OkfExporter, OkfProvenance, OkfRelation}; +pub use ports::okf::{ + validate_okf_document, OkfDocument, OkfEntity, OkfExporter, OkfProvenance, OkfRelation, + OkfValidationError, +}; #[cfg(feature = "sqlite")] pub use ports::sqlite_memory::SqliteMemoryStore; diff --git a/src/ports/okf.rs b/src/ports/okf.rs index 079c1ff8..76af6e29 100644 --- a/src/ports/okf.rs +++ b/src/ports/okf.rs @@ -123,6 +123,21 @@ pub struct OkfDocument { pub tags: Vec, } +/// A structural violation in an [`OkfDocument`]. +/// +/// This validates the exported OKF graph, not the separate HTTP ingest +/// payload used to create a session. In particular, OKF entity types such as +/// `intent` and `gate` are graph node types, not chat-message roles. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OkfValidationError { + /// JSON-like location of the invalid field. + pub field: String, + /// Stable machine-readable classification. + pub code: String, + /// Human-readable explanation of the violation. + pub message: String, +} + impl OkfDocument { /// Create a bare OKF document with provenance derived from `bundle`. #[must_use] @@ -149,6 +164,72 @@ impl OkfDocument { } } +/// Validate the structural invariants of an exported OKF v1 document. +/// +/// The validator intentionally does not require ingest-only fields such as +/// `created_at`, `messages`, or chat roles: canonical OKF documents are +/// knowledge graphs containing entities, relations, and provenance. +#[must_use] +pub fn validate_okf_document(document: &OkfDocument) -> Vec { + let mut errors = Vec::new(); + + if document.okf != "1.0" { + errors.push(OkfValidationError { + field: "okf".into(), + code: "unsupported_version".into(), + message: format!("expected OKF version \"1.0\", got {:?}", document.okf), + }); + } + + if document.provenance.source_id != document.source_id { + errors.push(OkfValidationError { + field: "provenance.source_id".into(), + code: "source_id_mismatch".into(), + message: format!( + "provenance.source_id {:?} does not match source_id {:?}", + document.provenance.source_id, document.source_id + ), + }); + } + + let mut entity_ids = std::collections::HashSet::with_capacity(document.entities.len()); + for (index, entity) in document.entities.iter().enumerate() { + if entity.id.is_empty() { + errors.push(OkfValidationError { + field: format!("entities[{index}].id"), + code: "empty_entity_id".into(), + message: "entity id must not be empty".into(), + }); + } + if !entity_ids.insert(entity.id.as_str()) { + errors.push(OkfValidationError { + field: format!("entities[{index}].id"), + code: "duplicate_entity_id".into(), + message: format!("entity id {:?} is duplicated", entity.id), + }); + } + } + + for (index, relation) in document.relations.iter().enumerate() { + if !entity_ids.contains(relation.source.as_str()) { + errors.push(OkfValidationError { + field: format!("relations[{index}].source"), + code: "dangling_relation_source".into(), + message: format!("relation source {:?} is not an entity id", relation.source), + }); + } + if !entity_ids.contains(relation.target.as_str()) { + errors.push(OkfValidationError { + field: format!("relations[{index}].target"), + code: "dangling_relation_target".into(), + message: format!("relation target {:?} is not an entity id", relation.target), + }); + } + } + + errors +} + // --------------------------------------------------------------------------- // Port trait // --------------------------------------------------------------------------- @@ -179,6 +260,91 @@ mod tests { use super::*; use std::io::{Error, Write}; + fn valid_document() -> OkfDocument { + OkfDocument::new(&ContinuationBundle::new("session-42"), "forge") + } + + #[test] + fn validation_rejects_unsupported_version() { + let mut document = valid_document(); + document.okf = "2.0".into(); + let errors = validate_okf_document(&document); + assert!(errors + .iter() + .any(|error| { error.code == "unsupported_version" && error.field == "okf" })); + } + + #[test] + fn validation_rejects_provenance_source_mismatch() { + let mut document = valid_document(); + document.provenance.source_id = "other-session".into(); + let errors = validate_okf_document(&document); + assert!(errors.iter().any(|error| { + error.code == "source_id_mismatch" && error.field == "provenance.source_id" + })); + } + + #[test] + fn validation_rejects_duplicate_entity_ids() { + let mut document = valid_document(); + let entity = OkfEntity { + id: "entity-0".into(), + r#type: "intent".into(), + label: "goal".into(), + properties: serde_json::Value::Null, + }; + document.entities = vec![entity.clone(), entity]; + let errors = validate_okf_document(&document); + assert!(errors.iter().any(|error| { + error.code == "duplicate_entity_id" && error.field == "entities[1].id" + })); + } + + #[test] + fn validation_rejects_empty_entity_ids() { + let mut document = valid_document(); + document.entities.push(OkfEntity { + id: String::new(), + r#type: "intent".into(), + label: "goal".into(), + properties: serde_json::Value::Null, + }); + let errors = validate_okf_document(&document); + assert!(errors + .iter() + .any(|error| { error.code == "empty_entity_id" && error.field == "entities[0].id" })); + } + + #[test] + fn validation_rejects_dangling_relation_source() { + let mut document = valid_document(); + document.relations.push(OkfRelation { + source: "missing".into(), + target: "present".into(), + r#type: "grounds".into(), + provenance: document.provenance.clone(), + }); + let errors = validate_okf_document(&document); + assert!(errors.iter().any(|error| { + error.code == "dangling_relation_source" && error.field == "relations[0].source" + })); + } + + #[test] + fn validation_rejects_dangling_relation_target() { + let mut document = valid_document(); + document.relations.push(OkfRelation { + source: "present".into(), + target: "missing".into(), + r#type: "grounds".into(), + provenance: document.provenance.clone(), + }); + let errors = validate_okf_document(&document); + assert!(errors.iter().any(|error| { + error.code == "dangling_relation_target" && error.field == "relations[0].target" + })); + } + #[test] fn new_document_copies_source_provenance_and_starts_empty() { let bundle = ContinuationBundle::new("session-42"); diff --git a/tests/alloc_profile.rs b/tests/alloc_profile.rs index e6c7cb3a..b89d6c46 100644 --- a/tests/alloc_profile.rs +++ b/tests/alloc_profile.rs @@ -81,9 +81,10 @@ fn alloc_profile_script_self_check_parses_args_and_ceilings() { ); assert!(stdout.contains("Profiler: dhat"), "expected profiler echo, got:\n{stdout}"); } - Err(error) => { - if cfg!(target_os = "windows") { - panic!("failed to spawn pwsh for self-check: {error}"); + Err(_error) => { + #[cfg(target_os = "windows")] + { + panic!("failed to spawn pwsh for self-check: {_error}"); } let (max_bytes, total_blocks) = load_profile(); diff --git a/tests/replay_breadth.rs b/tests/replay_breadth.rs index 4842147a..913a8207 100644 --- a/tests/replay_breadth.rs +++ b/tests/replay_breadth.rs @@ -119,7 +119,7 @@ fn w44_b6_each_generated_fixture_is_well_formed_okf_v1() { Err(e) => bad.push((slug.to_string(), format!("json parse: {e}"))), } } - assert!(bad.is_empty(), "W44-B6 fixtures failed shape check: {bad:#?}",); + assert!(bad.is_empty(), "W44-B6 fixtures failed shape check: {bad:#?}"); assert_eq!(parsed, W44_B6_SLUGS.len(), "parsed count mismatch"); } @@ -128,18 +128,18 @@ fn w44_b6_generator_script_present_and_importable() { // The generator is a Python script, not part of the Rust crate, but its // presence on disk is part of the W44-B6 deliverable. let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/corpus-generate.py"); - assert!(script.is_file(), "expected corpus generator at {}", script.display(),); + assert!(script.is_file(), "expected corpus generator at {}", script.display()); let raw = std::fs::read_to_string(&script).expect("read corpus-generate.py"); assert!(raw.contains("OKF_VERSION"), "generator must define OKF_VERSION"); - assert!(raw.contains("FIXTURE_SPECS"), "generator must declare FIXTURE_SPECS",); - assert!(raw.contains("FAILURE_FIXTURES"), "generator must isolate failure-mode fixtures",); + assert!(raw.contains("FIXTURE_SPECS"), "generator must declare FIXTURE_SPECS"); + assert!(raw.contains("FAILURE_FIXTURES"), "generator must isolate failure-mode fixtures"); } #[test] fn w44_b6_corpus_breadth_doc_present() { let doc = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("docs/ops/corpus-breadth.md"); - assert!(doc.is_file(), "expected docs/ops/corpus-breadth.md at {}", doc.display(),); + assert!(doc.is_file(), "expected docs/ops/corpus-breadth.md at {}", doc.display()); let raw = std::fs::read_to_string(&doc).expect("read corpus-breadth.md"); assert!(raw.contains("C08 L73"), "doc must reference C08 L73 pillar"); - assert!(raw.contains("Wave-44"), "doc must reference Wave-44 (W44-B6) close-out",); + assert!(raw.contains("Wave-44"), "doc must reference Wave-44 (W44-B6) close-out"); }