diff --git a/crates/bevy_bible_graph/src/README.md b/crates/bevy_bible_graph/src/README.md index 563f3ba4..c11f7425 100644 --- a/crates/bevy_bible_graph/src/README.md +++ b/crates/bevy_bible_graph/src/README.md @@ -64,6 +64,8 @@ share one canonical set of graph fixtures. because the current Bevy ECS coupling is renderer-local and the extraction would not create an independent ownership boundary. +The graph camera disables tonemapping: this renderer displays authored graph data without requiring the optional tonemapping LUT assets. + ## Invariants - The workspace timeline presentation enum derives `Default` with `CameraAnchoredPanel` explicitly designated; its initial mode and serialized diff --git a/crates/bevy_bible_graph/src/native_render.rs b/crates/bevy_bible_graph/src/native_render.rs index 0759d82f..96c0575c 100644 --- a/crates/bevy_bible_graph/src/native_render.rs +++ b/crates/bevy_bible_graph/src/native_render.rs @@ -7,6 +7,7 @@ use std::time::{Duration, Instant}; use bevy::app::AppExit; use bevy::asset::{Asset, embedded_asset}; use bevy::color::LinearRgba; +use bevy::core_pipeline::tonemapping::Tonemapping; use bevy::input::ButtonState; use bevy::input::keyboard::KeyboardInput; use bevy::input::mouse::{MouseMotion, MouseScrollUnit, MouseWheel}; @@ -759,6 +760,7 @@ fn spawn_bible_graph_renderer_window_scene( ) { commands.spawn(( Camera3d::default(), + Tonemapping::None, Transform::from_xyz(0.0, 0.0, 900.0).looking_at(Vec3::ZERO, Vec3::Y), BibleGraphNativeCamera, )); diff --git a/crates/server/examples/portfolio_authored_demo.rs b/crates/server/examples/portfolio_authored_demo.rs new file mode 100644 index 00000000..aac883f5 --- /dev/null +++ b/crates/server/examples/portfolio_authored_demo.rs @@ -0,0 +1,253 @@ +//! Original authored demo. Calls public backend commands; performs no inference. +//! Run with a fresh XDG_DATA_HOME to keep personal projects separate. +use eidetic_core::{ + contracts::*, + timeline::node::{NodeId, StoryLevel}, +}; +use eidetic_server::{command_service as commands, project_service as projects, state::AppState}; +use serde_json::json; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let path = dirs::data_local_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("eidetic/projects/Signal_at_Low_Tide/project.db"); + if path.exists() { + return Err(format!( + "Demo project already exists at {}; use a fresh XDG_DATA_HOME", + path.display() + ) + .into()); + } + let state = AppState::new().await; + let result = prepare(&state).await; + state.shutdown_tasks_async().await; + println!("{}", result?); + Ok(()) +} + +async fn prepare(state: &AppState) -> Result> { + projects::create_project( + state, + projects::CreateProjectRequest { + name: "Signal at Low Tide".into(), + template: "single_cam".into(), + }, + ) + .await?; + projects::update_project(state, projects::UpdateProjectRequest { + name: None, + premise: Some("When a decommissioned lighthouse answers her father's call sign, a radio technician must find the sender before the island's last night of light.".into()), + })?; + projects::save_project(state, projects::SaveProjectRequest { path: None }).await?; + let project = state.project.lock().as_ref().unwrap().clone(); + let root = project + .timeline + .nodes + .iter() + .find(|n| n.level == StoryLevel::Premise) + .unwrap() + .id; + for act in project + .timeline + .nodes + .iter() + .filter(|n| n.level == StoryLevel::Act) + { + commands::delete_timeline_node( + state, + CommandEnvelope::new(DeleteTimelineNodeCommand { node_id: act.id }), + ) + .await?; + } + let acts = [ + "ACT I | Reception", + "ACT II | Echoes", + "ACT III | Transmission", + ]; + let scene_names = [ + "01 | A signal at dusk", + "02 | The harbor ledger", + "03 | A room of echoes", + "04 | The missing page", + "05 | Light in the fog", + "06 | An answer at dawn", + ]; + let scene_notes = [ + "Mara discovers a light in the abandoned lighthouse. Ivo insists the power is gone. A radio pulse carries her father's call sign.", + "The harbor ledger places the last service boat at Northbreak on the night her father disappeared. Ivo has torn out one page.", + "Inside the lighthouse, Mara finds a warm relay and a reel-to-reel recorder. The voice on the tape is not the voice on the radio.", + "Ivo admits he kept the last log entry to protect Mara. She chooses to hear the whole story rather than another version of it.", + "A storm cuts the road. Mara bridges the old relay by hand while Ivo climbs to the lamp room. They send the call together.", + "A fishing boat answers. Her father once saved its crew. At dawn, Mara leaves the receiver on and watches the island wake.", + ]; + let mut scenes = Vec::new(); + for (i, name) in acts.iter().enumerate() { + let act = NodeId(uuid::Uuid::new_v4()); + let sequence = NodeId(uuid::Uuid::new_v4()); + let start = (i as u64) * 440_000; + for (id, parent, level, label, lo, hi) in [ + (act, root, StoryLevel::Act, *name, start, start + 440_000), + ( + sequence, + act, + StoryLevel::Sequence, + ["The shore", "The keeper's story", "The last light"][i], + start, + start + 440_000, + ), + ] { + commands::create_timeline_node_from_core_command( + state, + CommandEnvelope::new(CreateTimelineNodeCommand { + node_id: id, + parent_id: Some(parent), + level, + name: label.into(), + start_ms: lo, + end_ms: hi, + beat_type: None, + }), + ) + .await?; + } + for j in 0..2 { + let index = i * 2 + j; + let id = NodeId(uuid::Uuid::new_v4()); + let lo = start + (j as u64) * 220_000; + let hi = lo + 220_000; + commands::create_timeline_node_from_core_command( + state, + CommandEnvelope::new(CreateTimelineNodeCommand { + node_id: id, + parent_id: Some(sequence), + level: StoryLevel::Scene, + name: scene_names[index].into(), + start_ms: lo, + end_ms: hi, + beat_type: None, + }), + ) + .await?; + commands::set_timeline_node_notes( + state, + CommandEnvelope::new(SetTimelineNodeNotesCommand { + node_id: id, + notes: scene_notes[index].into(), + expected: None, + }), + ) + .await?; + scenes.push((id, lo, hi)); + } + } + let first_blocks = [ + ( + ScriptBlockKind::SceneHeading, + "EXT. NORTHBREAK HARBOR - DUSK", + ), + ( + ScriptBlockKind::Action, + "The last ferry has gone. A ribbon of fog gathers beyond the breakwater.\n\nMARA VALE, 32, crouches beside a marine receiver. Salt on the dial. A strip of blue tape over the broken handle.", + ), + (ScriptBlockKind::Character, "IVO"), + ( + ScriptBlockKind::Dialogue, + "They cut the power to that lighthouse six years ago.", + ), + ( + ScriptBlockKind::Action, + "A lamp turns once in the distance. Then the receiver clicks.", + ), + (ScriptBlockKind::Character, "MARA"), + ( + ScriptBlockKind::Dialogue, + "Then someone forgot to tell the light.", + ), + ( + ScriptBlockKind::Action, + "She adjusts the dial. Three short pulses. Her father's call sign.\n\nIvo stops winding the rope.", + ), + ]; + for (kind, text) in first_blocks { + commands::create_script_block( + state, + CommandEnvelope::new(CreateScriptBlockCommand { + document_id: ScriptDocumentId::new("script.document.main")?, + source_node_id: scenes[0].0, + expected_start_ms: scenes[0].1, + expected_end_ms: scenes[0].2, + block_kind: kind, + text: text.into(), + }), + ) + .await?; + } + for (index, heading) in [ + "INT. HARBOR OFFICE - NIGHT", + "INT. LIGHTHOUSE CONTROL ROOM - NIGHT", + "INT. LAMP ROOM - NIGHT", + "EXT. NORTHBREAK ROAD - PRE-DAWN", + "EXT. NORTHBREAK HARBOR - DAWN", + ] + .iter() + .enumerate() + { + let scene = scenes[index + 1]; + for (kind, text) in [ + (ScriptBlockKind::SceneHeading, *heading), + (ScriptBlockKind::Action, scene_notes[index + 1]), + ] { + commands::create_script_block( + state, + CommandEnvelope::new(CreateScriptBlockCommand { + document_id: ScriptDocumentId::new("script.document.main")?, + source_node_id: scene.0, + expected_start_ms: scene.1, + expected_end_ms: scene.2, + block_kind: kind, + text: text.into(), + }), + ) + .await?; + } + } + for (index, (id, schema, name, text, fields)) in [ + ("demo.mara", "character", "Mara Vale", "A marine radio technician returning to an island she thought she had left behind.", vec![("profile", "summary", "Repairs the island's radios; avoids the story of her father's disappearance."), ("profile", "tagline", "She can repair anything except a goodbye."), ("profile", "motivation", "Find the sender before the lighthouse is demolished."), ("appearance", "costume", "Salt-worn blue jacket; brass whistle.")]), + ("demo.ivo", "character", "Ivo Rook", "Harbor keeper. His silence has protected the wrong person for too long.", vec![("profile", "summary", "The keeper of Northbreak's harbor ledger and its unfinished stories."), ("profile", "tagline", "The island's memory has a keeper.")]), + ("demo.lighthouse", "location", "Northbreak Lighthouse", "The old lighthouse is scheduled for demolition at dawn.", vec![("environment", "description", "A dark lamp room above a warm relay cabinet. Salt, brass and six years of dust."), ("environment", "weather", "Sea fog. A spring storm gathering offshore.")]), + ("demo.harbor", "location", "Northbreak Harbor", "The harbor is the island's threshold: every arrival carries a story.", vec![("environment", "description", "A stone breakwater, a closed ferry office and the last working radio mast.")]), + ("demo.receiver", "prop", "Marine Receiver", "An old coastguard receiver with a broken handle and a strip of blue tape.", vec![("identity", "description", "Mara's father's receiver. Three short pulses break through its static."), ("identity", "significance", "The call sign connects an unfinished goodbye to a living witness.")]), + ].into_iter().enumerate() { + commands::create_bible_graph_node(state, serde_json::from_value(json!({"id":uuid::Uuid::new_v4(),"payload":{ + "node_id":id,"schema_key":schema,"name":name,"sort_order":index + }}))?).await?; + commands::set_bible_graph_node_text(state, CommandEnvelope::new(SetBibleGraphNodeTextCommand { + node_id: BibleGraphNodeId::new(id)?, text: text.into(), + })).await?; + for (order, (part, field, value)) in fields.into_iter().enumerate() { + commands::set_bible_graph_field(state, CommandEnvelope::new(SetBibleGraphFieldCommand { + node_id: BibleGraphNodeId::new(id)?, part_id: BibleGraphPartId::new(format!("part.default.{id}.{part}"))?, part_key: BibleGraphPartKey::new(part)?, part_name: match part { "profile" => "Profile", "appearance" => "Appearance", "environment" => "Environment", "identity" => "Identity", _ => part }.into(), part_sort_order: if part == "appearance" {20} else {10}, + field_id: BibleGraphFieldId::new(format!("field.default.{id}.{part}.{field}"))?, field_key: BibleGraphFieldKey::new(field)?, value: Some(FieldValue::Text(value.into())), field_sort_order: ((order as u32) + 1) * 10, + })).await?; + } + } + for (index, (from, to, label)) in [ + ("demo.mara", "demo.ivo", "Questions the keeper"), + ("demo.mara", "demo.receiver", "Repairs and carries"), + ("demo.receiver", "demo.lighthouse", "Receives its signal"), + ("demo.ivo", "demo.harbor", "Keeps its ledger"), + ("demo.lighthouse", "demo.harbor", "Guides the crossing"), + ] + .into_iter() + .enumerate() + { + commands::set_bible_graph_edge(state, serde_json::from_value(json!({"id":uuid::Uuid::new_v4(),"payload":{ + "edge_id":format!("demo.edge.{index}"),"from_node_id":from,"to_node_id":to,"edge_kind":"references","label":label,"directed":true,"sort_order":index + }}))?).await?; + } + let saved = projects::save_project(state, projects::SaveProjectRequest { path: None }).await?; + Ok( + json!({"project_path":saved["saved"],"project_name":"Signal at Low Tide","authorship":"capture-only original authored fiction; public backend commands; no generation or inference", "scene_count":scenes.len(), "first_scene_id":scenes[0].0.0}), + ) +} diff --git a/crates/server/src/README.md b/crates/server/src/README.md index f9a38d08..e5f111b3 100644 --- a/crates/server/src/README.md +++ b/crates/server/src/README.md @@ -155,6 +155,8 @@ increase coupling by hiding the transaction invariant. - Splitting `affect_store.rs` solely by size: rejected because the current coupling is a transaction/revision invariant rather than unrelated ownership. +Saved-project listings read modification metadata from project.db, so editing an existing project updates the displayed time even when its directory is unchanged. Timestamps remain decimal Unix seconds. + ## Invariants - Child-plan list projections recover existing durable canonical proposals and diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs index 55b7bca7..5e731993 100644 --- a/crates/server/src/persistence.rs +++ b/crates/server/src/persistence.rs @@ -843,8 +843,7 @@ pub async fn list_projects(base_dir: &Path) -> Vec { continue; } - let modified = entry - .metadata() + let modified = fs::metadata(&project_file) .await .ok() .and_then(|m| m.modified().ok()) @@ -885,6 +884,23 @@ mod tests { use super::{load_project_sync, save_project_sync}; + #[tokio::test] + async fn project_list_uses_saved_database_modification_time() { + use std::time::{Duration, UNIX_EPOCH}; + let root = std::env::temp_dir().join(format!("eidetic-project-list-{}", Uuid::new_v4())); + let directory = root.join("Demo"); + std::fs::create_dir_all(&directory).unwrap(); + let database = std::fs::File::create(directory.join("project.db")).unwrap(); + database + .set_modified(UNIX_EPOCH + Duration::from_secs(1_700_000_000)) + .unwrap(); + + let entries = super::list_projects(&root).await; + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].modified, "1700000000"); + std::fs::remove_dir_all(root).unwrap(); + } + fn temp_project_path(label: &str) -> std::path::PathBuf { std::env::temp_dir().join(format!("eidetic-persistence-{label}-{}.db", Uuid::new_v4())) } diff --git a/docs/reports/native-workflow-acceptance/README.md b/docs/reports/native-workflow-acceptance/README.md new file mode 100644 index 00000000..00d2f753 --- /dev/null +++ b/docs/reports/native-workflow-acceptance/README.md @@ -0,0 +1,157 @@ +# Native workflow acceptance — 2026-10-11 + +Source: `18d6c5e0c51c2c6b75f161939ce4b32f0bfb6338`, based on main +`7e963d58036c4a576a6750d7fc0b7916262f03e4`. This branch is separate from +[Pumas integration PR #29](https://github.com/MrScripty/Eidetic/pull/29). +The screenshots are actual native Tauri/WebKit and Bevy windows. The frontend +is the production Vite build, served locally; no transport or model adapter is +stubbed. All story content is original authored fiction, not generated output. +No model weights, personal data, credentials or raw runtime logs are included. + +## Results + +- Opened a saved SQLite project through the native project list. Modification + dates now parse the backend's Unix seconds, use the database's modification + time, and have an unknown-date fallback. +- Selected the first scene on the main timeline and read its Notes, screenplay + and inherited structure. Renamed that clip, verified SQLite, then restored its + title through the UI after reloading the title draft. +- Edited the first screenplay heading to `EXT. NORTHBREAK HARBOR - BLUE HOUR`; + saved it, reopened the project with the rebuilt application and verified it. + A subsequent unsaved screenplay edit was cancelled without replacing that text. +- Edited Mara Vale's tagline to `She can repair anything except a goodbye. + Until tonight.` Saved it through the field's Save control, verified SQLite and + read the same value after project reopen. The initial automation selected + Discard draft instead of Save; only the corrected, persisted edit qualifies. +- Opened, focused, hid and reopened the same native graph owner. Its OS window + became unmapped on hide and its readiness status returned after reopening. + Disabling graph-camera tonemapping removes the missing-LUT startup error. +- Opened the separate native timeline as the first Bevy runner in a fresh + application process. Its geometry rendered; closing destroyed its OS window. +- Verified graph → timeline startup rejection and timeline runner recreation + rejection through visible UI error status, with an actionable restart message + and no `RecreationAttempt` panic in the guarded run. The main timeline remains + usable after rejection. + +## Captures and proposed captions + +| Capture | Honest caption | +| --- | --- | +| [screenplay-timeline.png](screenplay-timeline.png) | Eidetic's screenplay editor and six-scene timeline, using an original authored demo. Saved text was verified after reopening the project. | +| [story-bible-timeline.png](story-bible-timeline.png) | Character details beside the selected scene and episode timeline. The authored story-bible edit was saved and verified after reopen. | +| [project-open.png](project-open.png) | Reopening a saved local project with a readable modification date. | +| [graph-controls.png](graph-controls.png) | Graph controls and the authored project's relationships; the native diagram is displayed in a separate window. | +| [native-graph.png](native-graph.png) | The current native Bevy graph window rendering authored story-bible nodes. This is minimal renderer evidence, with a linear layout and an empty lower panel. | +| [native-timeline.png](native-timeline.png) | The separate native timeline rendering geometry from the saved project. This minimal renderer omits the labels available in the main editor. | +| [second-renderer-error.png](second-renderer-error.png) | A visible, actionable error when a second native renderer would recreate winit's event loop. | + +Use the first two views for application presentation. The minimal native views +are supporting evidence of current implementation limits, not redesigned mockups. +Pixels were captured directly from native windows using ImageMagick `import`; +they have not been edited. See [provenance.json](provenance.json) for dimensions, +source identities, runtime details and file hashes. + +## Local reproduction + +On Linux with the normal documented GTK/WebKit and graphics dependencies: + +```bash +EIDETIC_FETCH_PUMAS=1 scripts/prepare-pumas-dependency.sh +cd ui +npm ci +npm run build +cd .. +export XDG_DATA_HOME="$(mktemp -d /tmp/eidetic-authored-demo.XXXXXX)" +cargo run --locked -p eidetic-server --example portfolio_authored_demo +# Terminal 1, production frontend: +npm --prefix ui run preview -- --host 127.0.0.1 --port 5173 --strictPort +# Terminal 2, with the same XDG_DATA_HOME: +cargo run --locked -p eidetic-desktop --bin eidetic-desktop +``` + +The fixture refuses to overwrite an existing `Signal at Low Tide` database. +Open Project → `Signal_at_Low_Tide`; select the first scene, edit/save a +screenplay block or a Bible → Mara Vale field, then reopen and compare. Try a +timeline title draft and cancellation separately. Use the main timeline normally. +Open the floating graph first for its renderer checks; restart the app before +opening the separate floating timeline first. + +Install dependencies inside each worktree. The earlier capture's node_modules +symlink caused Vite to deny the Svelte hydration entry outside its workspace. +This acceptance used an ordinary worktree-local `npm ci`, not an access-rule +bypass. The cloud environment used writable staged Debian dependencies and a +dependency-only WebKit helper/bundle path relocation; no application-source +transport shim or fabricated UI state was used. + +## Native renderer limit + +Graph and timeline currently own separate Bevy/winit runners. Winit permits one +event loop per process, including after a runner exits. The new guard reserves +that ownership atomically before native startup and returns existing runner-error +status for a second reservation. Showing a still-running hidden graph reuses its +owner. A terminated runner requires an application restart. Unified multi-window +rendering needs a separate architecture change; this branch does not implement it. + +## Validation + +- Frontend: 603 tests passed; Svelte check found zero errors/warnings; lint, + formatting and production build passed. +- Desktop: 95 tests passed, including concurrent native event-loop reservation. +- Native graph crate: 61 tests passed, one existing display test ignored. The + actual native graph and timeline checks above supplement controlled fixtures. +- Server persistence: six tests passed, including database-vs-directory mtime. +- Authored fixture: seeded and saved in a fresh profile; repeat refused before + overwriting the database. +- Workspace clippy with all targets and warnings denied passed. Decision + traceability passed for the committed source change. +- Exact-head CI status is reported with the branch/PR outcome separately. + +## Deferred Pumas inference acceptance + +There are no chat or embedding weights in this executor. The user's unpublished +Pumas changes are not available here. No generation, embedding, cloud inference +or paid inference was executed. Reference embedding/reindex/retrieval, local +model load/cancel/unload, and owned-vs-borrowed inference cleanup remain deferred +to the coordinated producer and PR #29 integration; this branch retains main's +older Pumas pin and does not modify the producer repository. + +After the parent publishes the producer fixes and coherently updates PR #29's +pin/lockfile, use that branch's +[operator instructions](https://github.com/MrScripty/Eidetic/blob/codex/pumas-local-inference/docs/pumas-local-inference.md). +From its Eidetic checkout: + +```bash +EIDETIC_FETCH_PUMAS=1 scripts/prepare-pumas-dependency.sh +cargo test --locked -p eidetic-server --lib pumas +cargo build --manifest-path ../../ai-systems/Pumas-Library/rust/Cargo.toml -p pumas-rpc +``` + +That test command uses explicitly controlled producer fixtures; it does not +qualify real-model inference. With already installed compatible local models and +native runtime libraries, run the built producer: + +```bash +../../ai-systems/Pumas-Library/rust/target/debug/pumas-rpc \ + --launcher-root "$PUMAS_ROOT" --port 8080 +``` + +Run Eidetic in another terminal: + +```bash +EIDETIC_PUMAS_ROOT="$PUMAS_ROOT" \ + cargo run --locked -p eidetic-desktop --bin eidetic-desktop +``` + +Use the AI panel's separate chat and embedding provider/model/profile selections: +load each, cancel an admitted load, retry, generate with an authored reference +containing a distinctive fact, and inspect the exact prompt for retrieval +contribution. Save/reopen and reindex; change the embedding model/revision and +verify stale-index rejection. Unload embeddings and then chat, checking visible +failures. Try unsupported profiles and an inference-disabled producer. Closing +Eidetic must preserve a borrowed service and its loaded models. + +For the owning-bootstrap check, stop the separately launched test producer first +and additionally set `EIDETIC_PUMAS_RPC_BIN` to that exact built binary before +starting Eidetic. Verify its owned service/provider cleanup on close using the +producer's local lifecycle evidence. Keep authentication and runtime logs local. +Do not download gated models or provide credentials in chat to perform these checks. diff --git a/docs/reports/native-workflow-acceptance/graph-controls.png b/docs/reports/native-workflow-acceptance/graph-controls.png new file mode 100644 index 00000000..55f1bafd Binary files /dev/null and b/docs/reports/native-workflow-acceptance/graph-controls.png differ diff --git a/docs/reports/native-workflow-acceptance/native-graph.png b/docs/reports/native-workflow-acceptance/native-graph.png new file mode 100644 index 00000000..1f82988a Binary files /dev/null and b/docs/reports/native-workflow-acceptance/native-graph.png differ diff --git a/docs/reports/native-workflow-acceptance/native-timeline.png b/docs/reports/native-workflow-acceptance/native-timeline.png new file mode 100644 index 00000000..dd3ac35d Binary files /dev/null and b/docs/reports/native-workflow-acceptance/native-timeline.png differ diff --git a/docs/reports/native-workflow-acceptance/project-open.png b/docs/reports/native-workflow-acceptance/project-open.png new file mode 100644 index 00000000..846a2c5a Binary files /dev/null and b/docs/reports/native-workflow-acceptance/project-open.png differ diff --git a/docs/reports/native-workflow-acceptance/provenance.json b/docs/reports/native-workflow-acceptance/provenance.json new file mode 100644 index 00000000..9c046aa7 --- /dev/null +++ b/docs/reports/native-workflow-acceptance/provenance.json @@ -0,0 +1,127 @@ +{ + "repository": "MrScripty/Eidetic", + "branch": "codex/native-workflow-acceptance", + "source_commit": "18d6c5e0c51c2c6b75f161939ce4b32f0bfb6338", + "base_main": "7e963d58036c4a576a6750d7fc0b7916262f03e4", + "ui_source_tree": "e9538762bed0ab7cef0d15edb2203f3b719091d4", + "native_executable_sha256": "c848a99462975bb06e317d12f760bae783c678518365577870cf40b10ca6424f", + "pumas_dependency_pin": "a94fd92021f27fdeedb6e2de6e01c41c250ef576", + "fixture_source": "crates/server/examples/portfolio_authored_demo.rs", + "fixture_source_sha256": "96f40bbeb1f70dc306c47ed685a77ad136c7830d4b322d40cac6445f8d0d35b7", + "fixture_authorship": "original authored fiction; public backend commands; no model generation", + "fixture_counts": { + "acts": 3, + "sequences": 3, + "scenes": 6, + "screenplay_blocks": 18, + "authored_bible_nodes": 5, + "authored_edges": 5, + "auto_created_schema_roots": 8 + }, + "ui_saved_edits": { + "first_heading": "EXT. NORTHBREAK HARBOR - BLUE HOUR", + "mara_tagline": "She can repair anything except a goodbye. Until tonight.", + "first_scene_title_restored": "01 | A signal at dusk" + }, + "frontend": "production Vite build served at http://localhost:5173 in real native Tauri WebKit window", + "capture": "native window ImageMagick import; no pixel edits", + "runtime": { + "os": "Debian13", + "rust": "1.92", + "node": "24.12", + "gtk": "3.24", + "webkit": "2.52", + "display": "Xvfb 1920x1080x24", + "gpu": "Mesa llvmpipe software Vulkan", + "dependency_staging": "workspace-only Debian libraries; local WebKit dependency paths relocated; application source and transport unchanged except committed fixes" + }, + "images": { + "graph-controls.png": { + "sha256": "97b67b59003a1dfbe718232d3e6f87e905ab6ef19826d601e043b8c81d0fea17", + "pixels": [ + 1440, + 960 + ], + "captured_at_utc": "2026-10-11T21:02:39.897635+00:00" + }, + "native-graph.png": { + "sha256": "b335ae86403183ad5d683fda206fc2a6d40c6f737aea65bfd28d8834a0fc6f34", + "pixels": [ + 1280, + 720 + ], + "captured_at_utc": "2026-10-11T21:02:39.753636+00:00" + }, + "native-timeline.png": { + "sha256": "228992665dc57a3f790abb44a28e9d3174eeb62aa1fcf6c55aade991507aa532", + "pixels": [ + 1280, + 360 + ], + "captured_at_utc": "2026-10-11T21:03:48.657018+00:00" + }, + "project-open.png": { + "sha256": "12d4e3d73f1397eee109427b06ae9df7adbab97f774e1e7c6c21dfda4007a8ca", + "pixels": [ + 1440, + 960 + ], + "captured_at_utc": "2026-10-11T21:04:22.148732+00:00" + }, + "screenplay-timeline.png": { + "sha256": "d8f85a18ac887ad5821e944eade2efa7f987bcff72ec9c7e767d87580812db16", + "pixels": [ + 1440, + 960 + ], + "captured_at_utc": "2026-10-11T21:03:52.664999+00:00" + }, + "second-renderer-error.png": { + "sha256": "cd82d424700e9863f7ef24aab712a07ef4e7759b581652db3a9f87846f0ec100", + "pixels": [ + 1440, + 960 + ], + "captured_at_utc": "2026-10-11T21:01:25.506324+00:00" + }, + "story-bible-timeline.png": { + "sha256": "7c8e223d55ca70f3aac34a5bcd2438a8a5922816c26711cea144ee749f1493e7", + "pixels": [ + 1440, + 960 + ], + "captured_at_utc": "2026-10-11T21:03:51.441010+00:00" + } + }, + "frontend_assets": { + "_app/env.js": "3c588b75cde522990022a70ade17b3a6db7068f685ac4cb8b31a104f7e258706", + "_app/immutable/assets/0._9ytKbrA.css": "44bb58ddfc052265e4457532fcd700e8c472ba08ba63e4fcff07a38fb0e10d1b", + "_app/immutable/assets/2.DOENdStS.css": "9c551136fe5ac128115505c1c9532d23d2bec18fd89e5aecc3db06a26990379c", + "_app/immutable/chunks/BMZf9QWp.js": "1494e50c85c2b19b4d43ae6d7574c5d29d41e77357e22d3adb40b05d9496043f", + "_app/immutable/chunks/BqKqCbIY.js": "969021d9c168e130dc2d1d1b2f5d3a8d16e2479d5e76cb36092ab5bca55e15c9", + "_app/immutable/chunks/CqB33THj.js": "965bc06a31248ba6c73e96598b1e44fae1444411827cebf4c8481d64c3dadbfa", + "_app/immutable/chunks/DTLJZQfz.js": "1d6758d02389f5da14f7f4dd6fe4ee4ef43beb3c4ec86da90963ed9d3bb26d22", + "_app/immutable/chunks/DY1PI5r1.js": "4ff787d724273dcd3b4d09796355ddb5397dfaa9317694a1b4db4cc32e4468ed", + "_app/immutable/chunks/Dno82FLv.js": "eabbf5f5ab76feeac081c96d439a80ad63e5f0ad9432f2c12a5a5ef586bb3aaa", + "_app/immutable/chunks/nwYdZ4hK.js": "20595f6f90d83d5d78840705f9283d96ed4b768eadd8568acfa5ba4777e75911", + "_app/immutable/entry/app.dXB80qUo.js": "02b887c9ecd15ea66052b50f6e432d04a32b4bdf69123aad0a5bf2cbb9e87817", + "_app/immutable/entry/start.CLITvshe.js": "3e7435879a5e9638a4bd4220a0f4a1a362d005482f5b6ac86939e014a7afec5d", + "_app/immutable/nodes/0.CPKvTq9C.js": "ac7323ec20993128c9a2a9f34081e585a4df362ff9c861ae6324fe6f45311d31", + "_app/immutable/nodes/1.WCf3w-RC.js": "4ba6fc552d437153676a2246d7c514cf05e435b739128a954d3a6de0893d2e5b", + "_app/immutable/nodes/2.CFWQkg-S.js": "e4b7f2366941356b8d1a0c93fc2cf3541c6d7c385a3106fe5bfa8742f442137d", + "_app/version.json": "0fafd687dbdd1878a1fc041dd7ea91a4c91f002b160c27b55e6c56c5bd82a364", + "index.html": "151c50f51d9e0cb6bc663f36e16bea545b1bd509a52edd7a91b8d2f1393716d7" + }, + "deferred": [ + "real chat/embedding inference", + "PR29 reference embedding/reindex/retrieval", + "model load/cancel/unload and owned-vs-borrowed inference cleanup", + "unified multiple Bevy native windows", + "native Windows/macOS UI acceptance" + ], + "preserved_integration_pr": { + "number": 29, + "head": "b09f9c288b8feeb5da3f1643b085b0338c106dd2", + "modified_by_this_branch": false + } +} diff --git a/docs/reports/native-workflow-acceptance/screenplay-timeline.png b/docs/reports/native-workflow-acceptance/screenplay-timeline.png new file mode 100644 index 00000000..f4a13a54 Binary files /dev/null and b/docs/reports/native-workflow-acceptance/screenplay-timeline.png differ diff --git a/docs/reports/native-workflow-acceptance/second-renderer-error.png b/docs/reports/native-workflow-acceptance/second-renderer-error.png new file mode 100644 index 00000000..cc1bb07f Binary files /dev/null and b/docs/reports/native-workflow-acceptance/second-renderer-error.png differ diff --git a/docs/reports/native-workflow-acceptance/story-bible-timeline.png b/docs/reports/native-workflow-acceptance/story-bible-timeline.png new file mode 100644 index 00000000..3d73e1b8 Binary files /dev/null and b/docs/reports/native-workflow-acceptance/story-bible-timeline.png differ diff --git a/src-tauri/src/README.md b/src-tauri/src/README.md index 412b10f1..30147e96 100644 --- a/src-tauri/src/README.md +++ b/src-tauri/src/README.md @@ -42,6 +42,8 @@ crates own project state and command semantics. - Moving command policy into Svelte stores: rejected because backend state is authoritative. +Native graph and timeline runners reserve one process-wide winit event loop. Hiding/showing the existing owner reuses it; another renderer or a terminated runner cannot create a second loop. Rejected starts return an actionable restart message through existing runner-error status instead of panicking. The main timeline remains usable. This is a bounded guard, not unified multi-window rendering. + ## Invariants - The desktop builder registers `command_script_block_create` alongside existing edit commands so an empty screenplay can be authored through the same native diff --git a/src-tauri/src/bevy_graph_host/README.md b/src-tauri/src/bevy_graph_host/README.md index 8663a9a5..1715dc28 100644 --- a/src-tauri/src/bevy_graph_host/README.md +++ b/src-tauri/src/bevy_graph_host/README.md @@ -42,6 +42,8 @@ drain boundary. - Driving native rendering directly from Svelte: rejected because Bevy owns the native window lifecycle. +Native graph and timeline runners reserve one process-wide winit event loop. Hiding/showing the existing owner reuses it; another renderer or a terminated runner cannot create a second loop. Rejected starts return an actionable restart message through existing runner-error status instead of panicking. The main timeline remains usable. This is a bounded guard, not unified multi-window rendering. + ## Invariants - The owner/supervisor owns all renderer thread lifecycle transitions. diff --git a/src-tauri/src/bevy_graph_host/window_thread.rs b/src-tauri/src/bevy_graph_host/window_thread.rs index 86bd8b99..c0c62933 100644 --- a/src-tauri/src/bevy_graph_host/window_thread.rs +++ b/src-tauri/src/bevy_graph_host/window_thread.rs @@ -37,6 +37,9 @@ pub enum NativeRendererWindowThreadResult { impl NativeRendererWindowThreadHandle { pub fn start(config: BibleGraphNativeWindowRunnerConfig) -> std::io::Result { + crate::renderer_window::reserve_native_event_loop( + crate::renderer_window::DesktopRendererWindowKind::BibleGraph, + )?; Self::start_with(config, run_controlled_minimal_bible_graph_native_window) } diff --git a/src-tauri/src/renderer_window.rs b/src-tauri/src/renderer_window.rs index 779eac3e..69822f41 100644 --- a/src-tauri/src/renderer_window.rs +++ b/src-tauri/src/renderer_window.rs @@ -5,6 +5,72 @@ pub enum DesktopRendererWindowKind { Timeline, } +// Winit permits one event loop per process, even after the first loop exits. +// A hidden renderer keeps its original runner; a new runner must not recreate it. +static NATIVE_EVENT_LOOP: std::sync::OnceLock = + std::sync::OnceLock::new(); + +pub(crate) fn reserve_native_event_loop(kind: DesktopRendererWindowKind) -> std::io::Result<()> { + reserve_event_loop(&NATIVE_EVENT_LOOP, kind) +} + +fn reserve_event_loop( + reservation: &std::sync::OnceLock, + kind: DesktopRendererWindowKind, +) -> std::io::Result<()> { + reservation.set(kind).map_err(|_| { + let owner = reservation.get().expect("event loop reservation is set"); + std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!( + "The native {owner:?} renderer already reserved this process's winit event loop. \ + Restart Eidetic and open the native {kind:?} renderer first. \ + The main timeline remains available." + ), + ) + }) +} + +#[cfg(test)] +mod event_loop_tests { + use super::*; + use std::sync::OnceLock; + + #[test] + fn rejects_a_second_renderer_and_recreation_with_actionable_error() { + for owner in [ + DesktopRendererWindowKind::BibleGraph, + DesktopRendererWindowKind::Timeline, + ] { + let reservation = OnceLock::new(); + reserve_event_loop(&reservation, owner).unwrap(); + for next in [ + DesktopRendererWindowKind::BibleGraph, + DesktopRendererWindowKind::Timeline, + ] { + let error = reserve_event_loop(&reservation, next).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert!(error.to_string().contains("Restart Eidetic")); + assert_eq!(reservation.get(), Some(&owner)); + } + } + } + + #[test] + fn concurrent_starts_admit_only_one_native_event_loop() { + let reservation = OnceLock::new(); + let accepted = std::thread::scope(|scope| { + let graph = scope + .spawn(|| reserve_event_loop(&reservation, DesktopRendererWindowKind::BibleGraph)); + let timeline = scope + .spawn(|| reserve_event_loop(&reservation, DesktopRendererWindowKind::Timeline)); + usize::from(graph.join().unwrap().is_ok()) + + usize::from(timeline.join().unwrap().is_ok()) + }); + assert_eq!(accepted, 1); + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] pub enum DesktopRendererWindowStrategy { diff --git a/src-tauri/src/timeline_renderer_window_thread.rs b/src-tauri/src/timeline_renderer_window_thread.rs index cd91a6b9..6611d494 100644 --- a/src-tauri/src/timeline_renderer_window_thread.rs +++ b/src-tauri/src/timeline_renderer_window_thread.rs @@ -41,6 +41,9 @@ pub enum TimelineRendererWindowProjectionUpdateError { impl TimelineRendererWindowThreadHandle { pub fn start(config: TimelineNativeWindowRunnerConfig) -> std::io::Result { + crate::renderer_window::reserve_native_event_loop( + crate::renderer_window::DesktopRendererWindowKind::Timeline, + )?; Self::start_with(config, run_controlled_minimal_timeline_native_window) } diff --git a/ui/src/lib/README.md b/ui/src/lib/README.md index e1dafe4a..12c80ff6 100644 --- a/ui/src/lib/README.md +++ b/ui/src/lib/README.md @@ -11,6 +11,8 @@ This directory holds the shared frontend surface for the Eidetic UI: typed API c | `types.ts` | Shared TypeScript mirrors of core timeline, story, and UI layout contracts. | | `bibleGraphSchemaTypes.ts` | Focused TypeScript mirrors for backend-owned bible graph schema projection shapes. | | `api.ts` | Tauri command helpers for project, references, AI, models, export, and persistence operations. | +| `projectModified.ts` | Formats persisted Unix-second modification timestamps with a readable unknown-date fallback. | +| `projectModified.test.ts` | Covers backend timestamp compatibility and invalid metadata. | | `desktopTransport.ts` | Tauri IPC detection and command invocation helpers for desktop-hosted frontend code. | | `commandApi.ts` | Browser-side command helper barrel for backend-owned commands and versioned command projections. | | `timelineCommandApi.ts` | Timeline-specific command helpers that send backend-owned commands through Tauri IPC. | diff --git a/ui/src/lib/components/layout/README.md b/ui/src/lib/components/layout/README.md index 85a01fb4..b4ee5a56 100644 --- a/ui/src/lib/components/layout/README.md +++ b/ui/src/lib/components/layout/README.md @@ -47,6 +47,8 @@ Keep the main composition in `AppShell.svelte` but isolate the bottom timeline s - Leaving the timeline directly in `AppShell.svelte` with more inline sizing logic: rejected because `AppShell.svelte` already exceeds the decomposition review threshold. - Letting the timeline continue to consume remaining flex height: rejected because it makes the bottom editing surface unstable across window sizes. +Project cards format backend Unix-second timestamps, retain compatibility with ISO dates, and display Unknown date when metadata is unavailable. + ## Invariants - Workspace modes remain freely navigable while a screenplay creation save is pending or uncertain. Project-session owners retain creation and per-block edit drafts/submissions diff --git a/ui/src/lib/components/layout/SplashScreen.svelte b/ui/src/lib/components/layout/SplashScreen.svelte index 2044b9e1..bac3ce11 100644 --- a/ui/src/lib/components/layout/SplashScreen.svelte +++ b/ui/src/lib/components/layout/SplashScreen.svelte @@ -1,4 +1,5 @@ {#if !projectState.current} @@ -148,7 +136,7 @@ onclick={() => handleLoadProject(proj.path)} > {proj.name} - {formatDate(proj.modified)} + {formatProjectModified(proj.modified)} {/each} diff --git a/ui/src/lib/projectModified.test.ts b/ui/src/lib/projectModified.test.ts new file mode 100644 index 00000000..f67db2eb --- /dev/null +++ b/ui/src/lib/projectModified.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { formatProjectModified } from './projectModified.js'; + +describe('saved project modification time', () => { + it('displays the backend Unix-second timestamp as the corresponding date', () => { + expect(formatProjectModified('1700000000')).toBe(formatProjectModified('2023-11-14T22:13:20Z')); + expect(formatProjectModified('0')).toBe(formatProjectModified('1970-01-01T00:00:00Z')); + }); + + it('shows a readable fallback when metadata is missing or invalid', () => { + for (const value of ['', ' ', 'invalid', '999999999999999999999']) { + expect(formatProjectModified(value)).toBe('Unknown date'); + } + }); +}); diff --git a/ui/src/lib/projectModified.ts b/ui/src/lib/projectModified.ts new file mode 100644 index 00000000..1f9189c0 --- /dev/null +++ b/ui/src/lib/projectModified.ts @@ -0,0 +1,11 @@ +/** The project listing stores filesystem modification times as Unix seconds. */ +export function formatProjectModified(value: string): string { + if (!value.trim()) return 'Unknown date'; + const date = /^\d+$/.test(value) ? new Date(Number(value) * 1000) : new Date(value); + if (!Number.isFinite(date.getTime())) return 'Unknown date'; + return ( + date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) + + ' ' + + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) + ); +}