diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa3692c6..347e7e64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,8 @@ on: branches: - main - "feat/**" + - "fix/**" + - "test/**" push: branches: - main diff --git a/crates/core/src/ai/README.md b/crates/core/src/ai/README.md index ee264032..071aba99 100644 --- a/crates/core/src/ai/README.md +++ b/crates/core/src/ai/README.md @@ -100,6 +100,14 @@ Legacy requests omit receipts and do not infer past name consumption. ## Tagged arc consumption +`GenerateRequest.arc_description_applicability` optionally records empty +Description fields on the actual selected arc tags in that same snapshot. +These `StoryArcFieldInput` values describe an applicability read, not supplied +description prose. Runtime forwards them unchanged; missing receipts and missing +owned field clocks remain unknown. Newly entered prose can identify those exact +saved Scene consumers through existing graph dependencies and targeted review. +No new provider, store or embedding dependency is introduced. + `GenerateRequest.arc_inputs` retains the exact tagged-arc prompt fields read by the backend in one SQLite snapshot with target custody. Domain construction leaves this optional receipt unknown; canonical server attachment supplies the @@ -109,3 +117,22 @@ arc labels are outside this bounded receipt. Child-plan arc binding is separate. Decision: carry evidence through the existing request/generation command rather than rereading arc metadata when output finishes or inventing legacy history. + +## Consumed ancestor Notes + +`GenerateRequest.ancestor_notes_inputs` optionally carries exact nonempty Notes +from the ancestor chain actually supplied in the generation prompt. Canonical +server attachment validates the prompt chain and captures the owned sparse Notes +field clocks in the same snapshot as the target. Runtime forwards these original +receipts through provider I/O to existing generation command history; it never +rebinds delayed output to today's Notes. Core-only and legacy requests leave the +receipt absent. A supplied empty list is a known read without authored ancestor +Notes, while missing history remains unknown. + +Decision: reuse `TimelineNotesInput`, existing command/proposal JSON and graph +revision bindings rather than introduce another memory owner. An authored Notes +change identifies exact saved Scene consumers and supplies original/current +ancestor evidence to the existing targeted review. Saved/manual/locked screenplay +requires explicit acceptance; writer-lock stale/ABA checks preserve drafts and +refuse changed generation reads. Sibling prose and new ancestor membership are +separate follow-ups. See `docs/plans/agent-story-workflows/ancestor-notes-review-gap.md`. diff --git a/crates/core/src/ai/backend.rs b/crates/core/src/ai/backend.rs index 37e856ee..f21a0154 100644 --- a/crates/core/src/ai/backend.rs +++ b/crates/core/src/ai/backend.rs @@ -87,6 +87,14 @@ pub trait AiBackend: Send + Sync { /// Everything the AI needs to generate content for a single story node. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenerateRequest { + /// Known-empty applicability reads; never description prose consumption. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arc_description_applicability: Option>, + /// Exact nonempty ancestor Notes supplied in the generation prompt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeline_title_inputs: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ancestor_notes_inputs: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub arc_inputs: Option>, /// The node to generate content for (at any level). @@ -146,6 +154,8 @@ pub struct SurroundingContext { /// A scene recap from a preceding node, including source identification. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RecapEntry { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_id: Option, pub arc_name: String, pub node_name: String, /// End time of the source node (ms), for ordering. diff --git a/crates/core/src/ai/helpers.rs b/crates/core/src/ai/helpers.rs index 3b9a86dd..d47ecec9 100644 --- a/crates/core/src/ai/helpers.rs +++ b/crates/core/src/ai/helpers.rs @@ -128,6 +128,7 @@ pub fn gather_recap_context( .unwrap_or("Untagged"); entries.push(RecapEntry { + node_id: Some(node.id), arc_name: arc_name.to_string(), node_name: node.name.clone(), end_time_ms: node.time_range.end_ms, diff --git a/crates/core/src/ai/prompt.rs b/crates/core/src/ai/prompt.rs index bdd5ee9e..26122538 100644 --- a/crates/core/src/ai/prompt.rs +++ b/crates/core/src/ai/prompt.rs @@ -47,6 +47,9 @@ pub fn build_generate_request(project: &Project, node_id: NodeId) -> Result` is a versioned read model wrapper for Svelte, Bevy, AI, and export projections. - Field value variants define typed persistence semantics; adding variants requires persistence and wire round-trip tests. @@ -175,6 +183,17 @@ or persistent schema. A distinct Notes dependency on the existing TimelineNode endpoint is independent of the screenplay-window selection epoch. Notes do not infer fictional time; existing explicit targeted acceptance owns replacement. +Manual Notes saving reuses this same `TimelineNotesInput` as optional +`SetTimelineNodeNotesCommand.expected` and selected editor `notes_read`. Baseline +`revision_event_id: null` is a known canonical read; missing editor receipt is +unknown. The guarded writer checks exact node/text/owned clock atomically, including +edit/restore ABA, before changing current rows. Guardless legacy JSON is unchanged. +The optional command-response `notes_read` is the original command's owned +historical receipt, so an acknowledgement retry cannot adopt a later writer's +Notes or replace newer current text. Session drafts never enter prompt inputs +before explicit Save Notes; existing targeted acceptance still owns screenplay +replacement. No schema or second revision clock is introduced. + `StoryArcPromptField` restricts dependency identity to name, description and arc_type. `StoryArcFieldInput` preserves exact supplied values and optional owned field revisions; missing template history is explicitly unbound. Generation and @@ -184,3 +203,20 @@ semantic endpoints extend with `story_arc_field`; no parallel store is added. Decision: per-field revision ownership makes color-only edits irrelevant while retaining semantic ABA, historical late consumption and explicit acceptance. + +`GenerateScriptBlockCommand.ancestor_notes_inputs` retains optional exact consumed +ancestor Notes receipts. `ScriptImpactProposalBinding` retains their previous and +current vectors plus owned absence revisions. Missing legacy fields remain +unknown; Some(empty) records a known empty set. Clearing a previously consumed +ancestor is an exact empty current input. Accepted updates refresh receipts through +existing command/proposal/dependency history, with no alternate persistent owner. + + +`RemoveScriptBlockCommand` captures one saved block revision for explicit manual removal. Ownership and timeline placement stay canonical; no client-authored replacement metadata is admitted. + +Timeline title authoring uses `SetTimelineNodeNameCommand` with a required exact +`TimelineNodeNameRead`. Known baseline titles have no fabricated field revision. + +Optional `TimelineTitleInput` receipts record exact consumed names and owned clocks +in generation/proposal JSON. Legacy absence remains unknown; baseline clock None +is a known canonical read. Recap source IDs avoid ambiguous name-based resolution. diff --git a/crates/core/src/contracts/mod.rs b/crates/core/src/contracts/mod.rs index e6a0b2ce..2a89ad41 100644 --- a/crates/core/src/contracts/mod.rs +++ b/crates/core/src/contracts/mod.rs @@ -94,12 +94,13 @@ pub use propagation_proposal::{ }; pub use script_document::{ BibleContextScope, CreateScriptBlockCommand, EditScriptBlockCommand, - GenerateScriptBlockCommand, ScriptBlock, ScriptBlockId, ScriptBlockKind, ScriptBlockProjection, - ScriptContextBlock, ScriptContextScope, ScriptContractError, ScriptDocument, ScriptDocumentId, - ScriptDocumentProjection, ScriptGenerationTarget, ScriptImpactCause, ScriptImpactProjection, - ScriptImpactReason, ScriptLock, ScriptLockId, ScriptPatch, ScriptPatchId, ScriptSegment, - ScriptSegmentId, ScriptSegmentProjection, ScriptSegmentStatus, ScriptSpan, ScriptSpanId, - ScriptSpanProvenance, SetScriptBlockCommand, SetScriptLockCommand, + GenerateScriptBlockCommand, RemoveScriptBlockCommand, ScriptBlock, ScriptBlockId, + ScriptBlockKind, ScriptBlockProjection, ScriptContextBlock, ScriptContextScope, + ScriptContractError, ScriptDocument, ScriptDocumentId, ScriptDocumentProjection, + ScriptGenerationTarget, ScriptImpactCause, ScriptImpactProjection, ScriptImpactReason, + ScriptLock, ScriptLockId, ScriptPatch, ScriptPatchId, ScriptSegment, ScriptSegmentId, + ScriptSegmentProjection, ScriptSegmentStatus, ScriptSpan, ScriptSpanId, ScriptSpanProvenance, + SetScriptBlockCommand, SetScriptLockCommand, }; pub use script_fact_reconciliation::{ RequestScriptFactProposalCommand, ScriptFactEditEvidence, ScriptFactField, @@ -107,7 +108,7 @@ pub use script_fact_reconciliation::{ }; pub use script_impact_review::{ RequestScriptImpactProposalCommand, ScriptImpactProposalBinding, ScriptRecallFactSelection, - ScriptRecallSelection, TimelineNotesInput, + ScriptRecallSelection, TimelineNotesInput, TimelineTitleInput, }; pub use selected_node_editor::{ SelectedNodeEditorAdjacentParents, SelectedNodeEditorNode, SelectedNodeEditorProjection, @@ -131,8 +132,9 @@ pub use story_arc::{ pub use timeline_command::{ ApplyTimelineChildCommand, ApplyTimelineChildrenCommand, CreateTimelineChildFromParentCommand, CreateTimelineNodeCommand, CreateTimelineRelationshipCommand, DeleteTimelineNodeCommand, - DeleteTimelineRelationshipCommand, SetTimelineNodeLockCommand, SetTimelineNodeNotesCommand, - SetTimelineNodeRangeCommand, SplitTimelineNodeCommand, TimelineNodeRangeRead, + DeleteTimelineRelationshipCommand, SetTimelineNodeLockCommand, SetTimelineNodeNameCommand, + SetTimelineNodeNotesCommand, SetTimelineNodeRangeCommand, SplitTimelineNodeCommand, + TimelineNodeNameRead, TimelineNodeRangeRead, }; pub use timeline_render::{ TimelineRenderAffectSample, TimelineRenderClip, TimelineRenderGap, TimelineRenderProjection, diff --git a/crates/core/src/contracts/script_document.rs b/crates/core/src/contracts/script_document.rs index b71cbdb0..326849d6 100644 --- a/crates/core/src/contracts/script_document.rs +++ b/crates/core/src/contracts/script_document.rs @@ -207,6 +207,15 @@ pub struct EditScriptBlockCommand { pub text: String, } +/// Remove one exact saved block without changing its timeline placement. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RemoveScriptBlockCommand { + pub document_id: ScriptDocumentId, + pub block_id: ScriptBlockId, + pub expected_revision_event_id: super::ChangeEventId, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ScriptContextBlock { pub document_id: ScriptDocumentId, @@ -258,6 +267,14 @@ pub struct ScriptGenerationTarget { /// Internal generation commit: its captured evidence is part of the replay signature. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct GenerateScriptBlockCommand { + /// Known-empty applicability reads; never description prose consumption. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arc_description_applicability: Option>, + /// Captured before provider I/O; absent legacy consumption stays unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeline_title_inputs: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ancestor_notes_inputs: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub arc_inputs: Option>, pub block: SetScriptBlockCommand, diff --git a/crates/core/src/contracts/script_impact_review.rs b/crates/core/src/contracts/script_impact_review.rs index 947031b8..f5e07a70 100644 --- a/crates/core/src/contracts/script_impact_review.rs +++ b/crates/core/src/contracts/script_impact_review.rs @@ -48,6 +48,25 @@ pub struct ScriptRecallFactSelection { /// Canonical evidence captured before provider I/O and rechecked at acceptance. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ScriptImpactProposalBinding { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arc_description_applicability_previous: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arc_description_applicability_current: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeline_title_previous: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeline_title_current: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeline_title_absence_revisions: + Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ancestor_notes_previous: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ancestor_notes_current: Option>, + /// Owned deletion clocks for previously consumed ancestors, never prompt prose. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ancestor_notes_absence_revisions: + Option>, /// Selected clip Notes actually supplied; absent legacy consumption stays unknown. #[serde(default, skip_serializing_if = "Option::is_none")] pub timeline_notes_previous: Option, @@ -86,6 +105,16 @@ pub struct ScriptImpactProposalBinding { pub script_context_scope: Option, } +/// Exact canonical timeline title supplied to a prompt. A missing owned clock +/// records a known baseline title, not an unknown or fabricated authoring event. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TimelineTitleInput { + pub node_id: crate::timeline::node::NodeId, + pub name: String, + pub revision_event_id: Option, +} + /// Exact Notes evidence, using the existing owned sparse timeline field history. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TimelineNotesInput { diff --git a/crates/core/src/contracts/selected_node_editor.rs b/crates/core/src/contracts/selected_node_editor.rs index bf55fd4e..6760fce6 100644 --- a/crates/core/src/contracts/selected_node_editor.rs +++ b/crates/core/src/contracts/selected_node_editor.rs @@ -41,6 +41,10 @@ pub struct SelectedNodeEditorNode { pub locked: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub range_read: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name_read: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes_read: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -140,6 +144,8 @@ impl From<&StoryNode> for SelectedNodeEditorNode { beat_type: node.beat_type.clone(), locked: node.locked, range_read: None, + name_read: None, + notes_read: None, } } } diff --git a/crates/core/src/contracts/timeline_command.rs b/crates/core/src/contracts/timeline_command.rs index 8595834b..9a2bc344 100644 --- a/crates/core/src/contracts/timeline_command.rs +++ b/crates/core/src/contracts/timeline_command.rs @@ -4,6 +4,22 @@ use crate::ai::backend::ChildPlanId; use crate::timeline::node::{BeatType, NodeId, StoryLevel}; use crate::timeline::relationship::{RelationshipId, RelationshipType}; +/// Exact canonical title and its owned field clock; None is a known baseline. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TimelineNodeNameRead { + pub name: String, + pub revision_event_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SetTimelineNodeNameCommand { + pub node_id: NodeId, + pub name: String, + pub expected: TimelineNodeNameRead, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SetTimelineNodeRangeCommand { pub node_id: NodeId, @@ -71,6 +87,8 @@ pub struct SetTimelineNodeLockCommand { pub struct SetTimelineNodeNotesCommand { pub node_id: NodeId, pub notes: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/server/examples/authoring_capture_fixture.rs b/crates/server/examples/authoring_capture_fixture.rs index 860ac5ec..aa651c62 100644 --- a/crates/server/examples/authoring_capture_fixture.rs +++ b/crates/server/examples/authoring_capture_fixture.rs @@ -41,6 +41,7 @@ async fn prepare(state: &AppState) -> Result Result Result>, + timeline_title_inputs: Option>, + ancestor_notes_inputs: Option>, arc_inputs: Option>, script_inputs: Option>, bible_inputs: Option>, @@ -99,6 +102,9 @@ pub(crate) async fn run_generation( node_uuid, stream, GenerationInputs { + arc_description_applicability: request.arc_description_applicability, + timeline_title_inputs: request.timeline_title_inputs, + ancestor_notes_inputs: request.ancestor_notes_inputs, arc_inputs: request.arc_inputs, script_inputs: request.script_context, bible_node_name_inputs: request.bible_node_name_inputs, @@ -379,6 +385,9 @@ async fn persist_generated_script_block( &CommandEnvelope { id: command.id, payload: GenerateScriptBlockCommand { + arc_description_applicability: inputs.arc_description_applicability, + timeline_title_inputs: inputs.timeline_title_inputs, + ancestor_notes_inputs: inputs.ancestor_notes_inputs, arc_inputs: inputs.arc_inputs, target_binding: inputs.target_binding, block: command.payload, diff --git a/crates/server/src/ai_generation_runtime_tests.rs b/crates/server/src/ai_generation_runtime_tests.rs index c2610503..3d7fb97f 100644 --- a/crates/server/src/ai_generation_runtime_tests.rs +++ b/crates/server/src/ai_generation_runtime_tests.rs @@ -3,6 +3,327 @@ use crate::project_service::replace_active_project; use eidetic_core::{Error, Template}; use futures::stream; +const CONSUMED_ANCESTOR_NOTES: &str = " Mara conceals the witness — 雨.\n\n "; +const UNCONSUMED_ACT_NOTES: &str = "Eli keeps a brass whistle in the unrelated Act."; + +const ENTERED_ARC_DESCRIPTION: &str = " Mara chooses exile — 雨.\n\n "; + +async fn empty_arc_description_fixture() -> ( + Fixture, + eidetic_core::story::arc::ArcId, + eidetic_core::story::arc::ArcId, +) { + use eidetic_core::timeline::node::StoryLevel; + let mut fixture = fixture().await; + let (project, _) = active_sqlite_project(&fixture.state).await.unwrap(); + fixture.node_id = project.timeline.nodes_at_level(StoryLevel::Scene)[0].id; + let arc = project.timeline.arcs_for_node(fixture.node_id)[0]; + let unrelated = project + .arcs + .iter() + .find(|candidate| candidate.id != arc) + .unwrap() + .id; + // Publicly commit a known-empty field; imported/unowned absence is distinct. + set_fixture_arc_description(&fixture, arc, "").await; + let (project, _) = active_sqlite_project(&fixture.state).await.unwrap(); + let mut request = + eidetic_core::ai::prompt::build_generate_request(&project, fixture.node_id).unwrap(); + crate::ai_service::attach_ai_generation_context( + &mut request, + fixture.path.clone(), + fixture.node_id, + ) + .await + .unwrap(); + assert!( + request + .tagged_arcs + .iter() + .any(|value| value.id == arc && value.description.is_empty()) + ); + assert!( + !request + .arc_inputs + .as_ref() + .unwrap() + .iter() + .any(|input| input.arc_id == arc + && input.field == eidetic_core::contracts::StoryArcPromptField::Description) + ); + assert!( + !crate::prompt_format::build_chat_prompt(&request) + .user + .contains(ENTERED_ARC_DESCRIPTION) + ); + persist_generated_script_block( + fixture.path.clone(), + fixture.node_id.0, + "Synthetic saved screenplay without arc description.".into(), + GenerationInputs { + arc_description_applicability: request.arc_description_applicability, + ancestor_notes_inputs: request.ancestor_notes_inputs, + arc_inputs: request.arc_inputs, + script_inputs: request.script_context, + bible_inputs: request.bible_inputs, + bible_node_name_inputs: request.bible_node_name_inputs, + bible_relationship_inputs: request.bible_relationship_inputs, + bible_context_scope: request.bible_context_scope, + script_context_scope: request.script_context_scope, + target_binding: request.generation_target, + ..GenerationInputs::default() + }, + ) + .await + .unwrap(); + (fixture, arc, unrelated) +} + +async fn set_fixture_arc_description( + fixture: &Fixture, + arc_id: eidetic_core::story::arc::ArcId, + text: &str, +) { + use eidetic_core::contracts::*; + crate::command_service::update_story_arc( + &fixture.state, + CommandEnvelope::new(SetStoryArcMetadataCommand { + arc_id, + name: None, + description: Some(text.into()), + arc_type: None, + color: None, + }), + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn public_known_empty_arc_description_entry_marks_saved_scene_for_review() { + use eidetic_core::contracts::*; + let (fixture, arc, _) = empty_arc_description_fixture().await; + let before = script(&fixture); + set_fixture_arc_description(&fixture, arc, ENTERED_ARC_DESCRIPTION).await; + let after = script(&fixture); + assert_saved_material_unchanged(&before.payload, &after.payload); + let (project, _) = active_sqlite_project(&fixture.state).await.unwrap(); + let mut fresh = + eidetic_core::ai::prompt::build_generate_request(&project, fixture.node_id).unwrap(); + crate::ai_service::attach_ai_generation_context( + &mut fresh, + fixture.path.clone(), + fixture.node_id, + ) + .await + .unwrap(); + assert!( + crate::prompt_format::build_chat_prompt(&fresh) + .user + .contains(ENTERED_ARC_DESCRIPTION) + ); + let changed = after + .payload + .segments + .iter() + .find(|row| { + row.segment.source_node_id.as_deref() == Some(fixture.node_id.0.to_string().as_str()) + }) + .unwrap() + .impact + .as_ref() + .unwrap(); + fixture.state.shutdown_tasks_async().await; + assert!( + changed.causes.iter().any(|cause| cause.input + == SemanticDependencyEndpoint::StoryArcField { + arc_id: arc, + field: StoryArcPromptField::Description + } + && cause + .dependency_id + .as_str() + .ends_with(&format!(".arc_description_applicability.{}", arc.0))), + "Known-empty tagged arc description became supplied without precise downstream Scene review" + ); +} + +#[tokio::test] +async fn public_known_empty_arc_clear_and_untagged_description_preserve_saved_scene() { + let (fixture, arc, unrelated) = empty_arc_description_fixture().await; + let before = script(&fixture); + set_fixture_arc_description(&fixture, arc, "").await; + set_fixture_arc_description(&fixture, unrelated, ENTERED_ARC_DESCRIPTION).await; + let after = script(&fixture); + assert_saved_material_unchanged(&before.payload, &after.payload); + assert_eq!( + before + .payload + .segments + .iter() + .map(|row| &row.impact) + .collect::>(), + after + .payload + .segments + .iter() + .map(|row| &row.impact) + .collect::>() + ); + fixture.state.shutdown_tasks_async().await; +} + +async fn ancestor_notes_fixture() -> (Fixture, NodeId, NodeId) { + use eidetic_core::timeline::node::StoryLevel; + let mut fixture = fixture().await; + let (project, _) = active_sqlite_project(&fixture.state).await.unwrap(); + let scene = project.timeline.nodes_at_level(StoryLevel::Scene)[0]; + fixture.node_id = scene.id; + let ancestor = scene.parent_id.unwrap(); + let unrelated = project + .timeline + .nodes_at_level(StoryLevel::Act) + .into_iter() + .find(|node| node.id != ancestor) + .unwrap() + .id; + set_fixture_notes(&fixture, ancestor, CONSUMED_ANCESTOR_NOTES).await; + set_fixture_notes(&fixture, unrelated, UNCONSUMED_ACT_NOTES).await; + let (project, _) = active_sqlite_project(&fixture.state).await.unwrap(); + let mut request = + eidetic_core::ai::prompt::build_generate_request(&project, fixture.node_id).unwrap(); + crate::ai_service::attach_ai_generation_context( + &mut request, + fixture.path.clone(), + fixture.node_id, + ) + .await + .unwrap(); + assert!( + request + .ancestor_chain + .iter() + .any(|node| { node.id == ancestor && node.content.notes == CONSUMED_ANCESTOR_NOTES }) + ); + let prompt = crate::prompt_format::build_chat_prompt(&request).user; + assert!(prompt.contains(CONSUMED_ANCESTOR_NOTES)); + assert!(!prompt.contains(UNCONSUMED_ACT_NOTES)); + // Explicitly synthetic output; use the existing canonical generation writer. + // This qualifies prompt consumption and lineage, not model narrative quality. + persist_generated_script_block( + fixture.path.clone(), + fixture.node_id.0, + "Synthetic screenplay: Mara conceals the witness.".into(), + GenerationInputs { + arc_description_applicability: request.arc_description_applicability, + ancestor_notes_inputs: request.ancestor_notes_inputs, + arc_inputs: request.arc_inputs, + script_inputs: request.script_context, + bible_inputs: request.bible_inputs, + bible_node_name_inputs: request.bible_node_name_inputs, + bible_relationship_inputs: request.bible_relationship_inputs, + bible_context_scope: request.bible_context_scope, + script_context_scope: request.script_context_scope, + target_binding: request.generation_target, + ..GenerationInputs::default() + }, + ) + .await + .unwrap(); + (fixture, ancestor, unrelated) +} + +async fn set_fixture_notes(fixture: &Fixture, node_id: NodeId, notes: &str) { + use eidetic_core::contracts::{CommandEnvelope, SetTimelineNodeNotesCommand}; + crate::command_service::set_timeline_node_notes( + &fixture.state, + CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, + node_id, + notes: notes.into(), + }), + ) + .await + .unwrap(); +} + +fn assert_saved_material_unchanged( + before: &eidetic_core::contracts::ScriptDocumentProjection, + after: &eidetic_core::contracts::ScriptDocumentProjection, +) { + assert_eq!(before.document, after.document); + assert_eq!(before.segments.len(), after.segments.len()); + for (before, after) in before.segments.iter().zip(&after.segments) { + assert_eq!(before.segment, after.segment); + assert_eq!(before.blocks, after.blocks); + } +} + +#[tokio::test] +async fn public_consumed_ancestor_notes_edit_marks_saved_scene_for_review() { + use eidetic_core::contracts::SemanticDependencyEndpoint; + let (fixture, ancestor, _) = ancestor_notes_fixture().await; + let before = script(&fixture); + assert!( + before + .payload + .segments + .iter() + .all(|segment| { !segment.impact.as_ref().unwrap().needs_review }) + ); + set_fixture_notes(&fixture, ancestor, " Mara reveals the witness — 雨.\n\n ").await; + let after = script(&fixture); + assert_saved_material_unchanged(&before.payload, &after.payload); + let segment = after + .payload + .segments + .iter() + .find(|segment| { + segment.segment.source_node_id.as_deref() + == Some(fixture.node_id.0.to_string().as_str()) + }) + .unwrap(); + let impact = segment.impact.as_ref().unwrap().clone(); + fixture.state.shutdown_tasks_async().await; + assert!( + impact.needs_review, + "Exact ancestor Notes supplied to generation changed without downstream scene review" + ); + assert!(impact.causes.iter().any(|cause| { + cause.input == SemanticDependencyEndpoint::TimelineNode { node_id: ancestor } + && cause.input_excerpt.as_deref() == Some(CONSUMED_ANCESTOR_NOTES) + })); +} + +#[tokio::test] +async fn public_unconsumed_act_notes_edit_preserves_saved_scene_review_state() { + let (fixture, _, unrelated) = ancestor_notes_fixture().await; + let before = script(&fixture); + set_fixture_notes( + &fixture, + unrelated, + "Eli discards the unrelated brass whistle.", + ) + .await; + let after = script(&fixture); + assert_saved_material_unchanged(&before.payload, &after.payload); + assert_eq!( + before + .payload + .segments + .iter() + .map(|segment| &segment.impact) + .collect::>(), + after + .payload + .segments + .iter() + .map(|segment| &segment.impact) + .collect::>() + ); + fixture.state.shutdown_tasks_async().await; +} + #[tokio::test] async fn public_consumed_bible_name_edit_marks_saved_screenplay_for_review() { use eidetic_core::contracts::*; @@ -38,6 +359,8 @@ async fn public_consumed_bible_name_edit_marks_saved_screenplay_for_review() { fixture.node_id.0, "Synthetic screenplay starring Mara.".into(), GenerationInputs { + arc_description_applicability: request.arc_description_applicability, + ancestor_notes_inputs: request.ancestor_notes_inputs, arc_inputs: request.arc_inputs, script_inputs: request.script_context, bible_inputs: request.bible_inputs, @@ -144,6 +467,8 @@ async fn public_relationship_edit_publishes_affected_review_from_original_genera "Synthetic screenplay: Mara trusts Eli.".into() )])), GenerationInputs { + arc_description_applicability: request.arc_description_applicability, + ancestor_notes_inputs: request.ancestor_notes_inputs, arc_inputs: request.arc_inputs, script_inputs: request.script_context, bible_inputs: request.bible_inputs, @@ -261,6 +586,8 @@ async fn independent_real_service_bible_fact_capture_output_and_manual_change_pu "Synthetic fixture screenplay: Mara carries red.".into(), )])), GenerationInputs { + arc_description_applicability: request.arc_description_applicability, + ancestor_notes_inputs: request.ancestor_notes_inputs, arc_inputs: request.arc_inputs, script_inputs: request.script_context, bible_node_name_inputs: request.bible_node_name_inputs, diff --git a/crates/server/src/ai_service.rs b/crates/server/src/ai_service.rs index 21b2cfa9..2ed4d49c 100644 --- a/crates/server/src/ai_service.rs +++ b/crates/server/src/ai_service.rs @@ -155,7 +155,18 @@ pub(crate) async fn attach_ai_generation_context_at_story_time( let range = request.target_node.time_range; let script_path = path.clone(); let expected_node = request.target_node.clone(); - let (blocks, scope, target, arcs, arc_inputs) = tokio::task::spawn_blocking(move || { + let expected_ancestors = request.ancestor_chain.clone(); + let mut script_request = request.clone(); + let ( + script_request, + scope, + target, + arcs, + arc_inputs, + ancestor_notes, + arc_description_applicability, + titles, + ) = tokio::task::spawn_blocking(move || { let conn = crate::sqlite::open_write_connection(&script_path) .map_err(|error| BackendError::internal(error.to_string()))?; crate::script_store::create_schema(&conn) @@ -165,6 +176,9 @@ pub(crate) async fn attach_ai_generation_context_at_story_time( .map_err(|error| BackendError::internal(error.to_string()))?; let target = crate::script_generation_target::capture(&tx, &expected_node) .map_err(|error| BackendError::bad_request(error.to_string()))?; + let ancestor_notes = + crate::ancestor_notes_lineage::capture_generation(&tx, node_id, &expected_ancestors) + .map_err(|error| BackendError::bad_request(error.to_string()))?; let blocks = crate::ai_script_context::load_script_context( &tx, node_id, @@ -180,19 +194,41 @@ pub(crate) async fn attach_ai_generation_context_at_story_time( &blocks, ) .map_err(|error| BackendError::internal(error.to_string()))?; + // Capture only names retained by the actual canonical prompt transform. + // Legacy recaps are removed here, so their titles were never consumed. + crate::ai_script_context::attach_script_context(&mut script_request, blocks); + let supplied_titles = crate::timeline_title_lineage::supplied_titles(&script_request) + .map_err(|error| BackendError::bad_request(error.to_string()))?; + let titles = crate::timeline_title_lineage::capture_generation(&tx, &supplied_titles) + .map_err(|error| BackendError::bad_request(error.to_string()))?; let (arcs, arc_inputs) = crate::story_arc_lineage::capture(&tx, node_id, &[]) .map_err(|error| BackendError::bad_request(error.to_string()))?; + let arc_description_applicability = + crate::arc_description_applicability::capture(&tx, node_id, &arc_inputs) + .map_err(|error| BackendError::bad_request(error.to_string()))?; tx.commit() .map_err(|error| BackendError::internal(error.to_string()))?; - Ok::<_, BackendError>((blocks, scope, target, arcs, arc_inputs)) + Ok::<_, BackendError>(( + script_request, + scope, + target, + arcs, + arc_inputs, + ancestor_notes, + arc_description_applicability, + titles, + )) }) .await .map_err(|error| BackendError::internal(format!("script context task failed: {error}")))??; - crate::ai_script_context::attach_script_context(request, blocks); + *request = script_request; request.tagged_arcs = arcs; request.arc_inputs = Some(arc_inputs); + request.arc_description_applicability = Some(arc_description_applicability); request.script_context_scope = Some(scope); request.generation_target = Some(target); + request.ancestor_notes_inputs = Some(ancestor_notes); + request.timeline_title_inputs = Some(titles); let ( bible_context, bible_inputs, diff --git a/crates/server/src/ancestor_notes_lineage.rs b/crates/server/src/ancestor_notes_lineage.rs new file mode 100644 index 00000000..d96b527b --- /dev/null +++ b/crates/server/src/ancestor_notes_lineage.rs @@ -0,0 +1,225 @@ +//! Consumed ancestor Notes in existing generation/proposal/dependency history. +use eidetic_core::contracts::*; +use eidetic_core::timeline::node::{NodeId, StoryNode}; +use rusqlite::Connection; + +use crate::{history_store::HistoryStoreError, timeline_notes_lineage as notes}; + +fn invalid() -> HistoryStoreError { + HistoryStoreError::InvalidValue("ancestor Notes evidence is stale or invalid".into()) +} + +fn ancestors(conn: &Connection, target: NodeId) -> Result, HistoryStoreError> { + let stack = crate::timeline_node_store::load_node_ancestor_stack(conn, target)?; + if !stack.iter().any(|node| node.id == target) { + return Err(invalid()); + } + Ok(stack + .into_iter() + .rev() + .filter(|node| node.id != target) + .collect()) +} + +/// Bind the exact prompt chain to one canonical snapshot before provider I/O. +pub(crate) fn capture_generation( + conn: &Connection, + target: NodeId, + expected: &[StoryNode], +) -> Result, HistoryStoreError> { + let current = ancestors(conn, target)?; + if current + .iter() + .map(|node| (node.id, &node.content.notes)) + .collect::>() + != expected + .iter() + .map(|node| (node.id, &node.content.notes)) + .collect::>() + { + return Err(invalid()); + } + capture_nonempty(conn, ¤t) +} + +fn capture_nonempty( + conn: &Connection, + ancestors: &[StoryNode], +) -> Result, HistoryStoreError> { + ancestors + .iter() + .filter(|node| !node.content.notes.is_empty()) + .map(|node| notes::capture(conn, node.id)) + .collect() +} + +pub(crate) fn validate_inputs( + conn: &Connection, + inputs: &[TimelineNotesInput], +) -> Result<(), HistoryStoreError> { + let mut seen = std::collections::BTreeSet::new(); + for input in inputs { + if !seen.insert(input.node_id.0) { + return Err(invalid()); + } + notes::validate_history(conn, input)?; + } + Ok(()) +} + +/// Writer-lock admission rejects a changed/cleared/ABA ancestor read atomically. +pub(crate) fn validate_admission( + conn: &Connection, + command: &GenerateScriptBlockCommand, +) -> Result<(), HistoryStoreError> { + let Some(inputs) = &command.ancestor_notes_inputs else { + return Ok(()); + }; + validate_inputs(conn, inputs)?; + let target = command + .block + .source_node_id + .as_deref() + .ok_or_else(invalid)?; + let target = NodeId(uuid::Uuid::parse_str(target).map_err(|_| invalid())?); + if capture_nonempty(conn, &ancestors(conn, target)?)? != *inputs { + return Err(invalid()); + } + Ok(()) +} + +fn dependency_id(event: ChangeEventId, node: NodeId) -> SemanticDependencyId { + SemanticDependencyId::new(format!("generation.{}.ancestor_notes.{}", event.0, node.0)) + .expect("nonempty ancestor Notes identity") +} + +pub(crate) fn dependencies( + conn: &Connection, + inputs: Option<&[TimelineNotesInput]>, + segment: &ScriptSegmentId, + event: ChangeEventId, + created_at_ms: u64, +) -> Result, HistoryStoreError> { + let Some(inputs) = inputs else { + return Ok(Vec::new()); + }; + validate_inputs(conn, inputs)?; + Ok(inputs + .iter() + .filter_map(|input| { + input.revision_event_id.map(|revision| SemanticDependency { + id: dependency_id(event, input.node_id), + source: SemanticDependencyEndpoint::ScriptSegment { + segment_id: segment.clone(), + }, + target: SemanticDependencyEndpoint::TimelineNode { + node_id: input.node_id, + }, + kind: SemanticDependencyKind::DerivesFrom, + rationale: Some( + "Ancestor Notes supplied to screenplay generation or reviewed update".into(), + ), + confidence: None, + created_at_ms, + revision_binding: Some(SemanticDependencyRevisionBinding { + source_revision_event_id: event, + target_revision_event_id: revision, + }), + }) + }) + .collect()) +} + +/// Missing legacy receipts stay unknown. Never replace them with current prose. +pub(crate) fn recorded( + conn: &Connection, + event: ChangeEventId, +) -> Result>, HistoryStoreError> { + let (kind, json): (String, String) = conn.query_row( + "SELECT c.payload_type,c.payload_json FROM commands c JOIN change_events e ON e.command_id=c.id WHERE e.id=?1", + [event.0.to_string()], |row| Ok((row.get(0)?,row.get(1)?)))?; + let inputs = match kind.as_str() { + "script.generate_block" => { + serde_json::from_str::(&json)?.ancestor_notes_inputs + } + "semantic.propagation_accept" => { + let command: AcceptPropagationProposalCommand = serde_json::from_str(&json)?; + let json: String = conn.query_row( + "SELECT binding_json FROM script_impact_proposal_bindings WHERE proposal_id=?1", + [command.proposal_id.as_str()], + |row| row.get(0), + )?; + serde_json::from_str::(&json)?.ancestor_notes_current + } + _ => None, + }; + if let Some(inputs) = &inputs { + validate_inputs(conn, inputs)?; + } + Ok(inputs) +} + +pub(crate) fn impact( + conn: &Connection, + event: ChangeEventId, +) -> Result, HistoryStoreError> { + let mut causes = Vec::new(); + for input in recorded(conn, event)?.iter().flatten() { + // Unowned legacy field history cannot be assigned a fabricated revision. + if input.revision_event_id.is_some() + && let Some(cause) = + notes::input_cause(conn, input, dependency_id(event, input.node_id))? + { + causes.push(cause); + } + } + Ok(causes) +} + +pub(crate) struct PreviewInputs { + pub previous: Option>, + pub current: Option>, + pub absent: Option>, +} + +/// Refresh only proven consumed identities; no unrelated/new ancestor expansion. +pub(crate) fn preview_inputs( + conn: &Connection, + event: ChangeEventId, +) -> Result { + let previous = recorded(conn, event)?; + let Some(inputs) = &previous else { + return Ok(PreviewInputs { + previous: None, + current: None, + absent: None, + }); + }; + let mut current = Vec::new(); + let mut absent = Vec::new(); + for input in inputs { + let live: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM nodes WHERE id=?1)", + [input.node_id.0.to_string()], + |row| row.get(0), + )?; + if live { + // Clearing is an exact read with an owned field clock, not absence. + current.push(notes::capture(conn, input.node_id)?); + } else { + absent.push(( + input.node_id, + notes::owned_revision(conn, input.node_id, None)?.ok_or_else(invalid)?, + )); + } + } + Ok(PreviewInputs { + previous, + current: Some(current), + absent: Some(absent), + }) +} + +#[cfg(test)] +#[path = "ancestor_notes_lineage_tests.rs"] +mod tests; diff --git a/crates/server/src/ancestor_notes_lineage_tests.rs b/crates/server/src/ancestor_notes_lineage_tests.rs new file mode 100644 index 00000000..7afcaf0b --- /dev/null +++ b/crates/server/src/ancestor_notes_lineage_tests.rs @@ -0,0 +1,437 @@ +use super::*; +use crate::script_impact_review::tests::{accept, edit, request}; +use crate::timeline_notes_lineage::tests::{logical_rows, materials, notes}; +use crate::{script_document_command, script_impact_review}; +use eidetic_core::Project; + +const OLD: &str = " Mara conceals the witness — 雨.\n\n "; +const NEW: &str = " Mara reveals the witness — 雨.\n\n "; +const HUMAN: &str = " Exact authored scene: Eli keeps his whistle — 雨.\n\n "; +const PREVIEW: &str = " Synthetic reviewed scene: Mara reveals the witness — 雨.\n\n "; + +#[test] +fn equal_notes_on_distinct_ancestors_keep_distinct_owned_dependency_identities() { + let (mut conn, mut project, mut generation, ancestor) = setup(false); + let outer = project.timeline.node(ancestor).unwrap().parent_id.unwrap(); + notes(&mut conn, &mut project, outer, OLD); + let target = generation.payload.target_binding.as_ref().unwrap().node_id; + let expected = project + .timeline + .ancestors_of(target) + .into_iter() + .cloned() + .collect::>(); + generation.payload.ancestor_notes_inputs = + Some(capture_generation(&conn, target, &expected).unwrap()); + script_document_command::apply_generated_script_block(&mut conn, &generation, 210).unwrap(); + let event = impact(&conn, &generation).generation_event_id; + assert_ne!(dependency_id(event, ancestor), dependency_id(event, outer)); + for node in [ancestor, outer] { + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM semantic_dependencies WHERE id=?1", + [dependency_id(event, node).as_str()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + } + notes(&mut conn, &mut project, outer, NEW); + let changed = impact(&conn, &generation); + assert_eq!(changed.causes.len(), 1); + assert_eq!( + changed.causes[0].input, + SemanticDependencyEndpoint::TimelineNode { node_id: outer } + ); + assert_eq!(changed.causes[0].dependency_id, dependency_id(event, outer)); +} + +#[test] +fn capture_refuses_a_prompt_chain_changed_since_the_initial_project_read() { + let (mut conn, mut project, generation, ancestor) = setup(false); + let target = generation.payload.target_binding.as_ref().unwrap().node_id; + let expected = project + .timeline + .ancestors_of(target) + .into_iter() + .cloned() + .collect::>(); + notes(&mut conn, &mut project, ancestor, NEW); + assert!(capture_generation(&conn, target, &expected).is_err()); +} + +fn setup( + persist: bool, +) -> ( + Connection, + Project, + CommandEnvelope, + NodeId, +) { + let (mut conn, mut project, mut generation) = crate::script_generation_target::tests::fixture(); + let node = generation.payload.target_binding.as_ref().unwrap().node_id; + let ancestor = project.timeline.ancestors_of(node)[0].id; + notes(&mut conn, &mut project, ancestor, OLD); + let chain = project + .timeline + .ancestors_of(node) + .into_iter() + .cloned() + .collect::>(); + generation.payload.ancestor_notes_inputs = + Some(capture_generation(&conn, node, &chain).unwrap()); + if persist { + script_document_command::apply_generated_script_block(&mut conn, &generation, 210).unwrap(); + } + (conn, project, generation, ancestor) +} + +fn impact( + conn: &Connection, + generation: &CommandEnvelope, +) -> ScriptImpactProjection { + crate::script_impact_projection::load_impact(conn, &generation.payload.block.segment_id) + .unwrap() + .unwrap() +} + +fn preview( + conn: &mut Connection, + generation: &CommandEnvelope, +) -> CommandEnvelope { + let command = request(conn, &generation.payload.block); + let binding = script_impact_review::capture(conn, &command.payload).unwrap(); + script_impact_review::record_proposal(conn, &command, binding, PREVIEW.into(), 220).unwrap(); + command +} + +#[test] +fn exact_ancestor_identity_review_and_explicit_acceptance_preserve_authored_material() { + let (mut conn, mut project, generation, ancestor) = setup(true); + edit(&mut conn, &generation.payload.block, HUMAN); + let original = recorded(&conn, impact(&conn, &generation).generation_event_id) + .unwrap() + .unwrap(); + assert_eq!(original.len(), 1); + assert_eq!(original[0].node_id, ancestor); + assert_eq!(original[0].notes, OLD); + let before = materials(&conn); + notes(&mut conn, &mut project, ancestor, NEW); + let changed = impact(&conn, &generation); + assert_eq!(changed.causes.len(), 1); + assert_eq!( + changed.causes[0].dependency_id, + dependency_id(changed.generation_event_id, ancestor) + ); + assert_eq!(changed.causes[0].input_excerpt.as_deref(), Some(OLD)); + assert_eq!(materials(&conn), before); + let command = preview(&mut conn, &generation); + let binding = script_impact_review::capture(&conn, &command.payload).unwrap(); + assert_eq!( + binding.ancestor_notes_previous.as_ref().unwrap()[0], + original[0] + ); + assert_eq!( + binding.ancestor_notes_current.as_ref().unwrap()[0].notes, + NEW + ); + assert_eq!( + materials(&conn), + before, + "preview must preserve the exact manual screenplay" + ); + let before_doc = + crate::script_store::load_document_projection(&conn, &generation.payload.block.document_id) + .unwrap() + .unwrap(); + accept(&mut conn, &command).unwrap(); + let after_doc = + crate::script_store::load_document_projection(&conn, &generation.payload.block.document_id) + .unwrap() + .unwrap(); + for (before, after) in before_doc.segments.iter().zip(&after_doc.segments) { + assert_eq!(before.segment, after.segment); + for (before, after) in before.blocks.iter().zip(&after.blocks) { + if before.block.id == generation.payload.block.block_id { + assert_eq!(after.block.text, PREVIEW); + } else { + assert_eq!(before, after); + } + } + } + let accepted = impact(&conn, &generation); + assert!(!accepted.needs_review); + assert_eq!( + recorded(&conn, accepted.generation_event_id).unwrap(), + binding.ancestor_notes_current + ); + notes( + &mut conn, + &mut project, + ancestor, + "Next authored ancestor decision", + ); + let next = impact(&conn, &generation); + assert_eq!(next.causes[0].input_excerpt.as_deref(), Some(NEW)); + assert_eq!( + next.causes[0].dependency_id, + dependency_id(accepted.generation_event_id, ancestor) + ); +} + +#[test] +fn delayed_generation_refuses_ancestor_notes_change_and_restore_atomically() { + for restored in [false, true] { + let (mut conn, mut project, generation, ancestor) = setup(false); + notes(&mut conn, &mut project, ancestor, NEW); + if restored { + notes(&mut conn, &mut project, ancestor, OLD); + } + let before = logical_rows(&conn); + assert!( + script_document_command::apply_generated_script_block(&mut conn, &generation, 230) + .is_err() + ); + assert_eq!(logical_rows(&conn), before); + } +} + +#[test] +fn duplicate_forged_notes_wrong_revision_and_unrelated_owned_receipts_are_refused_atomically() { + let (mut conn, mut project, generation, ancestor) = setup(false); + let unrelated = project + .timeline + .nodes + .iter() + .find(|node| { + node.id != generation.payload.target_binding.as_ref().unwrap().node_id + && node.parent_id == Some(ancestor) + }) + .unwrap() + .id; + notes(&mut conn, &mut project, unrelated, OLD); + let unrelated = notes::capture(&conn, unrelated).unwrap(); + let original = generation.payload.ancestor_notes_inputs.as_ref().unwrap()[0].clone(); + for forged in [ + vec![TimelineNotesInput { + notes: NEW.into(), + ..original.clone() + }], + vec![TimelineNotesInput { + revision_event_id: unrelated.revision_event_id, + ..original.clone() + }], + vec![TimelineNotesInput { + revision_event_id: None, + ..original.clone() + }], + vec![original.clone(), original.clone()], + vec![unrelated], + ] { + let mut forged_command = generation.clone(); + forged_command.payload.ancestor_notes_inputs = Some(forged); + let before = logical_rows(&conn); + assert!( + script_document_command::apply_generated_script_block(&mut conn, &forged_command, 230) + .is_err() + ); + assert_eq!(logical_rows(&conn), before); + } +} + +#[test] +fn pending_storage_and_acceptance_refuse_ancestor_aba_and_preserve_exact_state() { + let (mut conn, mut project, generation, ancestor) = setup(true); + notes(&mut conn, &mut project, ancestor, NEW); + let command = preview(&mut conn, &generation); + let binding = script_impact_review::capture(&conn, &command.payload).unwrap(); + notes( + &mut conn, + &mut project, + ancestor, + "Intervening ancestor decision", + ); + notes(&mut conn, &mut project, ancestor, NEW); + let before = logical_rows(&conn); + let mut late = command.clone(); + late.id = CommandId(uuid::Uuid::new_v4()); + late.payload.proposal_id = PropagationProposalId::new("late.ancestor.preview").unwrap(); + let mut delayed = binding; + delayed.request = late.payload.clone(); + assert!( + script_impact_review::record_proposal(&mut conn, &late, delayed, PREVIEW.into(), 230) + .is_err() + ); + assert!(accept(&mut conn, &command).is_err()); + assert_eq!(logical_rows(&conn), before); + assert_eq!( + recorded(&conn, impact(&conn, &generation).generation_event_id) + .unwrap() + .unwrap()[0] + .notes, + OLD + ); + let fresh = preview(&mut conn, &generation); + accept(&mut conn, &fresh).unwrap(); + assert!(!impact(&conn, &generation).needs_review); +} + +#[test] +fn clearing_and_accepting_records_an_exact_empty_read_for_later_changes() { + let (mut conn, mut project, generation, ancestor) = setup(true); + notes(&mut conn, &mut project, ancestor, ""); + let command = preview(&mut conn, &generation); + let binding = script_impact_review::capture(&conn, &command.payload).unwrap(); + let current = &binding.ancestor_notes_current.as_ref().unwrap()[0]; + assert_eq!(current.notes, ""); + assert!(current.revision_event_id.is_some()); + accept(&mut conn, &command).unwrap(); + assert!(!impact(&conn, &generation).needs_review); + notes(&mut conn, &mut project, ancestor, NEW); + let changed = impact(&conn, &generation); + assert!(changed.needs_review); + assert_eq!(changed.causes[0].input_excerpt.as_deref(), Some("")); +} + +#[test] +fn unrelated_notes_and_non_notes_events_do_not_invalidate_a_pending_ancestor_preview() { + let (mut conn, mut project, generation, ancestor) = setup(true); + notes(&mut conn, &mut project, ancestor, NEW); + let command = preview(&mut conn, &generation); + let unrelated = project + .timeline + .nodes + .iter() + .find(|node| node.parent_id == Some(ancestor)) + .unwrap() + .id; + notes( + &mut conn, + &mut project, + unrelated, + "Unconsumed unrelated Notes", + ); + for locked in [true, false] { + crate::timeline_command_history::record_set_timeline_node_lock_history( + &mut conn, + &project, + &CommandEnvelope::new(SetTimelineNodeLockCommand { + node_id: ancestor, + locked, + }), + 230, + ) + .unwrap(); + project.timeline.nodes = crate::timeline_node_store::load_nodes(&conn).unwrap(); + } + accept(&mut conn, &command).unwrap(); + assert!(!impact(&conn, &generation).needs_review); +} + +#[test] +fn missing_legacy_consumption_is_never_backfilled_from_current_ancestor_notes() { + let (mut conn, mut project, mut generation, ancestor) = setup(false); + generation.payload.ancestor_notes_inputs = None; + script_document_command::apply_generated_script_block(&mut conn, &generation, 210).unwrap(); + notes(&mut conn, &mut project, ancestor, NEW); + let current = impact(&conn, &generation); + assert!(!current.needs_review); + assert!( + recorded(&conn, current.generation_event_id) + .unwrap() + .is_none() + ); +} + +#[test] +fn ancestor_edit_and_preview_preserve_locked_manual_text_and_refuse_destructive_acceptance() { + let (mut conn, mut project, generation, ancestor) = setup(true); + edit(&mut conn, &generation.payload.block, HUMAN); + let document = + crate::script_store::load_document_projection(&conn, &generation.payload.block.document_id) + .unwrap() + .unwrap(); + let span = document + .segments + .iter() + .flat_map(|segment| &segment.blocks) + .find(|block| block.block.id == generation.payload.block.block_id) + .unwrap() + .spans[0] + .id + .clone(); + script_document_command::apply_set_script_lock( + &mut conn, + &CommandEnvelope::new(SetScriptLockCommand { + lock_id: ScriptLockId::new("lock.ancestor.manual").unwrap(), + span_id: span, + reason: "Keep my exact wording".into(), + }), + 215, + ) + .unwrap(); + let saved = materials(&conn); + notes(&mut conn, &mut project, ancestor, NEW); + assert_eq!(materials(&conn), saved); + let command = preview(&mut conn, &generation); + assert_eq!(materials(&conn), saved); + let before = logical_rows(&conn); + assert!(accept(&mut conn, &command).is_err()); + assert_eq!(logical_rows(&conn), before); +} + +#[test] +fn deleting_consumed_ancestor_retains_original_review_and_refuses_pending_acceptance() { + let (mut conn, mut project, generation, ancestor) = setup(true); + notes(&mut conn, &mut project, ancestor, NEW); + let command = preview(&mut conn, &generation); + crate::timeline_node_delete_history::record_delete_timeline_node_history( + &mut conn, + &project, + &CommandEnvelope::new(DeleteTimelineNodeCommand { node_id: ancestor }), + 230, + ) + .unwrap(); + let current = impact(&conn, &generation); + assert!(current.needs_review); + assert!(current.causes.iter().any(|cause| cause.input + == SemanticDependencyEndpoint::TimelineNode { node_id: ancestor } + && cause.reason == ScriptImpactReason::Deleted + && cause.input_excerpt.as_deref() == Some(OLD))); + let before = logical_rows(&conn); + assert!(accept(&mut conn, &command).is_err()); + assert_eq!(logical_rows(&conn), before); +} + +#[tokio::test] +async fn targeted_provider_receives_exact_current_ancestor_and_preserves_manual_text_on_failure() { + use eidetic_core::ai::backend::GenerateStream; + use futures::stream; + let (mut conn, mut project, generation, ancestor) = setup(true); + edit(&mut conn, &generation.payload.block, HUMAN); + notes(&mut conn, &mut project, ancestor, NEW); + let binding = + script_impact_review::capture(&conn, &request(&conn, &generation.payload.block).payload) + .unwrap(); + let before = logical_rows(&conn); + let result = + crate::script_impact_prompt::preview_with_provider(&binding, |prompt| async move { + assert!(prompt.user.contains(&format!( + "CURRENT CONSUMED ANCESTOR NOTES ({}):\n{NEW}", + ancestor.0 + ))); + assert!( + prompt + .user + .contains(&format!("TARGET BLOCK TO UPDATE:\n{HUMAN}")) + ); + let output: GenerateStream = Box::pin(stream::iter([ + Ok("Unfinished synthetic prefix".into()), + Err(eidetic_core::Error::AiBackend("Synthetic failure".into())), + ])); + Ok(output) + }) + .await; + assert!(result.is_err()); + assert_eq!(logical_rows(&conn), before); +} diff --git a/crates/server/src/arc_description_applicability.rs b/crates/server/src/arc_description_applicability.rs new file mode 100644 index 00000000..dde7a9c8 --- /dev/null +++ b/crates/server/src/arc_description_applicability.rs @@ -0,0 +1,211 @@ +//! Known-empty description applicability, separate from consumed arc prose. +use std::collections::BTreeSet; + +use eidetic_core::contracts::*; +use eidetic_core::timeline::node::NodeId; +use rusqlite::{Connection, params}; + +use crate::{history_store::HistoryStoreError, story_arc_lineage as arcs}; + +fn invalid() -> HistoryStoreError { + HistoryStoreError::InvalidValue("arc description applicability is stale or invalid".into()) +} + +/// Read only omitted descriptions on the actual selected tags, in the caller's +/// canonical snapshot. Ordinary supplied descriptions keep their existing owner. +pub(crate) fn capture( + conn: &Connection, + node: NodeId, + supplied: &[StoryArcFieldInput], +) -> Result, HistoryStoreError> { + let (selected, _) = arcs::capture(conn, node, supplied)?; + let selected_ids = selected.iter().map(|arc| arc.id.0).collect::>(); + for field in [StoryArcPromptField::Name, StoryArcPromptField::ArcType] { + if supplied + .iter() + .filter(|input| input.field == field) + .map(|input| input.arc_id.0) + .collect::>() + != selected_ids + { + return Err(invalid()); + } + } + let mut result = Vec::new(); + for arc in selected { + if supplied + .iter() + .any(|input| input.arc_id == arc.id && input.field == StoryArcPromptField::Description) + { + continue; + } + // No omitted-description receipt may acknowledge newly supplied prose. + if !arc.description.is_empty() { + return Err(invalid()); + } + let input = StoryArcFieldInput { + arc_id: arc.id, + field: StoryArcPromptField::Description, + value: String::new(), + revision_event_id: arcs::current_revision( + conn, + &SemanticDependencyEndpoint::StoryArcField { + arc_id: arc.id, + field: StoryArcPromptField::Description, + }, + )?, + }; + result.push(input); + } + result.sort_by_key(|input| input.arc_id.0); + Ok(result) +} + +pub(crate) fn validate_inputs( + conn: &Connection, + inputs: &[StoryArcFieldInput], +) -> Result<(), HistoryStoreError> { + let mut seen = BTreeSet::new(); + for input in inputs { + if input.field != StoryArcPromptField::Description + || !input.value.is_empty() + || !seen.insert(input.arc_id.0) + { + return Err(invalid()); + } + arcs::validate_history(conn, input)?; + } + Ok(()) +} + +pub(crate) fn validate_admission( + conn: &Connection, + command: &GenerateScriptBlockCommand, +) -> Result<(), HistoryStoreError> { + let Some(inputs) = &command.arc_description_applicability else { + return Ok(()); + }; + validate_inputs(conn, inputs)?; + let node = command + .block + .source_node_id + .as_deref() + .ok_or_else(invalid)?; + let node = NodeId(uuid::Uuid::parse_str(node).map_err(|_| invalid())?); + if capture( + conn, + node, + command.arc_inputs.as_deref().ok_or_else(invalid)?, + )? != *inputs + { + return Err(invalid()); + } + Ok(()) +} + +fn dependency_id(event: ChangeEventId, input: &StoryArcFieldInput) -> SemanticDependencyId { + SemanticDependencyId::new(format!( + "generation.{}.arc_description_applicability.{}", + event.0, input.arc_id.0 + )) + .expect("nonempty applicability identity") +} + +pub(crate) fn dependencies( + conn: &Connection, + inputs: Option<&[StoryArcFieldInput]>, + segment: &ScriptSegmentId, + event: ChangeEventId, + created_at_ms: u64, +) -> Result, HistoryStoreError> { + let Some(inputs) = inputs else { + return Ok(Vec::new()); + }; + validate_inputs(conn, inputs)?; + Ok(inputs.iter().filter_map(|input| input.revision_event_id.map(|revision| SemanticDependency { + id: dependency_id(event, input), + source: SemanticDependencyEndpoint::ScriptSegment { segment_id: segment.clone() }, + target: arcs::endpoint(input), + kind: SemanticDependencyKind::DerivesFrom, + rationale: Some("Known-empty description applicability read; description prose was not supplied".into()), + confidence: None, created_at_ms, + revision_binding: Some(SemanticDependencyRevisionBinding { source_revision_event_id: event, target_revision_event_id: revision }), + })).collect()) +} + +/// Missing legacy applicability stays unknown; no reconstruction from today's arc. +pub(crate) fn recorded( + conn: &Connection, + event: ChangeEventId, +) -> Result>, HistoryStoreError> { + let (kind, json): (String, String) = conn.query_row( + "SELECT c.payload_type,c.payload_json FROM commands c JOIN change_events e ON e.command_id=c.id WHERE e.id=?1", + [event.0.to_string()], |row| Ok((row.get(0)?, row.get(1)?)))?; + let inputs = match kind.as_str() { + "script.generate_block" => { + serde_json::from_str::(&json)?.arc_description_applicability + } + "semantic.propagation_accept" => { + let command: AcceptPropagationProposalCommand = serde_json::from_str(&json)?; + let json: String = conn.query_row( + "SELECT binding_json FROM script_impact_proposal_bindings WHERE proposal_id=?1", + [command.proposal_id.as_str()], + |row| row.get(0), + )?; + serde_json::from_str::(&json)? + .arc_description_applicability_current + } + _ => None, + }; + if let Some(inputs) = &inputs { + validate_inputs(conn, inputs)?; + } + Ok(inputs) +} + +pub(crate) fn impact( + conn: &Connection, + event: ChangeEventId, + segment: &ScriptSegmentId, +) -> Result, HistoryStoreError> { + let node: Option = conn.query_row( + "SELECT source_node_id FROM script_segments WHERE id=?1", + [segment.as_str()], + |row| row.get(0), + )?; + let Some(node) = node else { + return Ok(Vec::new()); + }; + let mut causes = Vec::new(); + for input in recorded(conn, event)?.iter().flatten() { + let Some(original) = input.revision_event_id else { + continue; + }; + let tagged: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM node_arcs WHERE node_id=?1 AND arc_id=?2)", + params![node, input.arc_id.0.to_string()], + |row| row.get(0), + )?; + let Some(arc) = crate::story_arc_store::load_arc(conn, &input.arc_id)? else { + continue; + }; + // Clearing withdraws newly available prose. Ordinary consumed-description + // clearing remains governed by its existing field dependency instead. + if !tagged || arc.description.is_empty() { + continue; + } + causes.push(ScriptImpactCause { + dependency_id: dependency_id(event, input), + input: arcs::endpoint(input), + consumed_revision_event_id: original, + current_revision_event_id: arcs::current_revision(conn, &arcs::endpoint(input))?, + reason: ScriptImpactReason::ContextChanged, + input_excerpt: Some("Description was not supplied (known empty).".into()), + }); + } + Ok(causes) +} + +#[cfg(test)] +#[path = "arc_description_applicability_tests.rs"] +mod tests; diff --git a/crates/server/src/arc_description_applicability_tests.rs b/crates/server/src/arc_description_applicability_tests.rs new file mode 100644 index 00000000..52dbceaa --- /dev/null +++ b/crates/server/src/arc_description_applicability_tests.rs @@ -0,0 +1,672 @@ +use super::*; +use crate::script_impact_review::tests::{accept, edit, fixture, request, text}; +use crate::{script_document_command, script_impact_review, story_arc_command}; +use eidetic_core::story::arc::{ArcId, ArcType, Color}; + +const NEW: &str = " Mara chooses exile — 雨.\n\n "; +const HUMAN: &str = " Exact authored screenplay: Mara keeps the letter — 雨.\n\n "; +const PREVIEW: &str = "Synthetic review: Mara leaves with the letter.\n\n"; + +fn create(conn: &mut Connection) -> ArcId { + let arc_id = ArcId::new(); + story_arc_command::record_create_story_arc_history( + conn, + &CommandEnvelope::new(CreateStoryArcCommand { + arc_id, + parent_arc_id: None, + name: "Exile".into(), + description: String::new(), + arc_type: ArcType::APlot, + color: Color::new(1, 2, 3), + }), + 20, + ) + .unwrap(); + arc_id +} + +fn metadata(conn: &mut Connection, arc_id: ArcId, description: Option<&str>, color: Option) { + story_arc_command::record_set_story_arc_metadata_history( + conn, + &CommandEnvelope::new(SetStoryArcMetadataCommand { + arc_id, + name: None, + description: description.map(str::to_owned), + arc_type: None, + color, + }), + 30, + ) + .unwrap(); +} + +fn setup( + persist: bool, +) -> ( + Connection, + ArcId, + GenerateScriptBlockCommand, + SetScriptBlockCommand, +) { + let (mut conn, _, _, b, c) = fixture(); + let arc = create(&mut conn); + conn.execute( + "INSERT INTO node_arcs(node_id,arc_id) VALUES (?1,?2)", + params![b.source_node_id, arc.0.to_string()], + ) + .unwrap(); + let node = NodeId(uuid::Uuid::parse_str(b.source_node_id.as_ref().unwrap()).unwrap()); + let (_, fields) = arcs::capture(&conn, node, &[]).unwrap(); + let command = GenerateScriptBlockCommand { + arc_description_applicability: Some(capture(&conn, node, &fields).unwrap()), + arc_inputs: Some(fields), + timeline_title_inputs: None, + ancestor_notes_inputs: None, + block: b, + script_inputs: Some(vec![]), + bible_inputs: Some(vec![]), + bible_node_name_inputs: None, + bible_relationship_inputs: None, + bible_context_scope: None, + script_context_scope: None, + target_binding: None, + }; + if persist { + script_document_command::apply_generated_script_block( + &mut conn, + &CommandEnvelope::new(command.clone()), + 25, + ) + .unwrap(); + } + (conn, arc, command, c) +} + +fn impact(conn: &Connection, command: &GenerateScriptBlockCommand) -> ScriptImpactProjection { + crate::script_impact_projection::load_impact(conn, &command.block.segment_id) + .unwrap() + .unwrap() +} + +fn rows(conn: &Connection) -> Vec<(String, Vec)> { + let mut result = Vec::new(); + for table in [ + "commands", + "change_events", + "object_revisions", + "object_revision_fields", + "semantic_dependencies", + "semantic_dependency_revisions", + "script_generations", + "script_documents", + "script_segments", + "script_blocks", + "script_spans", + "script_locks", + "propagation_proposals", + "script_impact_proposal_bindings", + ] { + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", + [table], + |row| row.get(0), + ) + .unwrap(); + if exists { + let mut stmt = conn + .prepare(&format!("SELECT * FROM {table} ORDER BY rowid")) + .unwrap(); + let count = stmt.column_count(); + let values = stmt + .query_map([], |row| { + Ok((0..count) + .map(|index| format!("{:?}", row.get_ref(index).unwrap())) + .collect::>() + .join("|")) + }) + .unwrap() + .collect::, _>>() + .unwrap(); + result.push((table.into(), values)); + } + } + result +} + +fn preview( + conn: &mut Connection, + command: &GenerateScriptBlockCommand, +) -> CommandEnvelope { + let request = request(conn, &command.block); + let binding = script_impact_review::capture(conn, &request.payload).unwrap(); + script_impact_review::record_proposal(conn, &request, binding, PREVIEW.into(), 40).unwrap(); + request +} + +#[test] +fn known_empty_description_allows_name_preview_and_preserves_applicability_after_acceptance() { + let (mut conn, arc, command, _) = setup(true); + edit(&mut conn, &command.block, HUMAN); + story_arc_command::record_set_story_arc_metadata_history( + &mut conn, + &CommandEnvelope::new(SetStoryArcMetadataCommand { + arc_id: arc, + name: Some("Chosen exile".into()), + description: None, + arc_type: None, + color: None, + }), + 30, + ) + .unwrap(); + let changed = impact(&conn, &command); + assert_eq!(changed.causes.len(), 1); + assert_eq!( + changed.causes[0].input, + SemanticDependencyEndpoint::StoryArcField { + arc_id: arc, + field: StoryArcPromptField::Name, + } + ); + let request = request(&conn, &command.block); + conn.execute( + "DELETE FROM node_arcs WHERE node_id=?1 AND arc_id=?2", + params![command.block.source_node_id, arc.0.to_string()], + ) + .unwrap(); + let before = rows(&conn); + assert!(script_impact_review::capture(&conn, &request.payload).is_err()); + assert_eq!(rows(&conn), before); + conn.execute( + "INSERT INTO node_arcs(node_id,arc_id) VALUES (?1,?2)", + params![command.block.source_node_id, arc.0.to_string()], + ) + .unwrap(); + let before = rows(&conn); + let binding = script_impact_review::capture(&conn, &request.payload).unwrap(); + assert_eq!( + rows(&conn), + before, + "Capturing preview must remain read-only" + ); + assert!( + !binding + .arc_inputs + .as_ref() + .unwrap() + .iter() + .any(|input| input.field == StoryArcPromptField::Description) + ); + assert_eq!( + binding.arc_description_applicability_current, + command.arc_description_applicability + ); + let request = preview(&mut conn, &command); + assert_eq!(text(&conn, &command.block), HUMAN); + accept(&mut conn, &request).unwrap(); + assert_eq!(text(&conn, &command.block), PREVIEW); + assert!(!impact(&conn, &command).needs_review); + metadata(&mut conn, arc, Some(NEW), None); + let changed = impact(&conn, &command); + assert_eq!(changed.causes.len(), 1); + let omission = &command.arc_description_applicability.as_ref().unwrap()[0]; + assert_eq!( + changed.causes[0].dependency_id, + dependency_id(changed.generation_event_id, omission) + ); +} + +#[test] +fn known_empty_second_tag_allows_description_preview_and_keeps_separate_lineage() { + let (mut conn, arc, mut command, _) = setup(false); + let other = create(&mut conn); + conn.execute( + "INSERT INTO node_arcs(node_id,arc_id) VALUES (?1,?2)", + params![command.block.source_node_id, other.0.to_string()], + ) + .unwrap(); + let node = + NodeId(uuid::Uuid::parse_str(command.block.source_node_id.as_ref().unwrap()).unwrap()); + let (_, fields) = arcs::capture(&conn, node, &[]).unwrap(); + command.arc_description_applicability = Some(capture(&conn, node, &fields).unwrap()); + command.arc_inputs = Some(fields); + assert_eq!( + command + .arc_description_applicability + .as_ref() + .unwrap() + .len(), + 2 + ); + script_document_command::apply_generated_script_block( + &mut conn, + &CommandEnvelope::new(command.clone()), + 25, + ) + .unwrap(); + edit(&mut conn, &command.block, HUMAN); + metadata(&mut conn, arc, Some(NEW), None); + let changed = impact(&conn, &command); + assert_eq!(changed.causes.len(), 1); + assert_eq!( + changed.causes[0].input, + SemanticDependencyEndpoint::StoryArcField { + arc_id: arc, + field: StoryArcPromptField::Description, + } + ); + let request = preview(&mut conn, &command); + let binding = script_impact_review::capture(&conn, &request.payload).unwrap(); + let descriptions = binding + .arc_inputs + .as_ref() + .unwrap() + .iter() + .filter(|input| input.field == StoryArcPromptField::Description) + .collect::>(); + assert_eq!(descriptions.len(), 1); + assert_eq!(descriptions[0].arc_id, arc); + assert_eq!(descriptions[0].value, NEW); + let omission = command + .arc_description_applicability + .as_ref() + .unwrap() + .iter() + .find(|input| input.arc_id == other) + .unwrap(); + assert_eq!( + binding.arc_description_applicability_current.as_deref(), + Some(std::slice::from_ref(omission)) + ); + assert_eq!(text(&conn, &command.block), HUMAN); + accept(&mut conn, &request).unwrap(); + assert_eq!(text(&conn, &command.block), PREVIEW); + assert!(!impact(&conn, &command).needs_review); + metadata( + &mut conn, + other, + Some("The other arc gains direction"), + None, + ); + let changed = impact(&conn, &command); + assert_eq!(changed.causes.len(), 1); + assert_eq!( + changed.causes[0].dependency_id, + dependency_id(changed.generation_event_id, omission) + ); +} + +#[test] +fn known_empty_tag_deletion_refuses_stale_preview_and_fresh_preview_binds_absence() { + let (mut conn, arc, command, _) = setup(true); + edit(&mut conn, &command.block, HUMAN); + story_arc_command::record_set_story_arc_metadata_history( + &mut conn, + &CommandEnvelope::new(SetStoryArcMetadataCommand { + arc_id: arc, + name: None, + description: None, + arc_type: Some(ArcType::BPlot), + color: None, + }), + 30, + ) + .unwrap(); + let old = preview(&mut conn, &command); + let delayed_request = request(&conn, &command.block); + let delayed = script_impact_review::capture(&conn, &delayed_request.payload).unwrap(); + story_arc_command::record_delete_story_arc_history( + &mut conn, + &CommandEnvelope::new(DeleteStoryArcCommand { arc_id: arc }), + 50, + ) + .unwrap(); + let before = rows(&conn); + assert!(accept(&mut conn, &old).is_err()); + assert!( + script_impact_review::record_proposal( + &mut conn, + &delayed_request, + delayed, + PREVIEW.into(), + 60, + ) + .is_err() + ); + assert_eq!(rows(&conn), before); + assert_eq!(text(&conn, &command.block), HUMAN); + let fresh = preview(&mut conn, &command); + let binding = script_impact_review::capture(&conn, &fresh.payload).unwrap(); + assert!(binding.arc_inputs.as_ref().unwrap().is_empty()); + assert!( + binding + .arc_description_applicability_current + .as_ref() + .unwrap() + .is_empty() + ); + assert_eq!(binding.arc_absence_revisions.as_ref().unwrap().len(), 1); + accept(&mut conn, &fresh).unwrap(); + assert!(!impact(&conn, &command).needs_review); +} + +#[test] +fn owned_omission_entry_reviews_manual_material_and_acceptance_installs_actual_description() { + let (mut conn, arc, command, c) = setup(true); + edit(&mut conn, &command.block, HUMAN); + let before = crate::script_store::load_document_projection(&conn, &command.block.document_id) + .unwrap() + .unwrap(); + let omission = command.arc_description_applicability.as_ref().unwrap()[0].clone(); + assert!(omission.value.is_empty() && omission.revision_event_id.is_some()); + assert!( + !command + .arc_inputs + .as_ref() + .unwrap() + .iter() + .any(|value| value.field == StoryArcPromptField::Description) + ); + metadata(&mut conn, arc, Some(NEW), None); + let changed = impact(&conn, &command); + assert_eq!(changed.causes.len(), 1); + assert_eq!( + changed.causes[0].dependency_id, + dependency_id(changed.generation_event_id, &omission) + ); + assert_eq!(changed.causes[0].input, arcs::endpoint(&omission)); + let stored: i64 = conn + .query_row( + "SELECT COUNT(*) FROM semantic_dependencies WHERE id=?1", + [changed.causes[0].dependency_id.as_str()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + stored, 1, + "Applicability must have an actual precise graph dependency" + ); + assert_eq!( + changed.causes[0].input_excerpt.as_deref(), + Some("Description was not supplied (known empty).") + ); + let request = preview(&mut conn, &command); + let binding = script_impact_review::capture(&conn, &request.payload).unwrap(); + assert_eq!( + binding.arc_description_applicability_previous, + command.arc_description_applicability + ); + assert!( + binding + .arc_description_applicability_current + .as_ref() + .unwrap() + .is_empty() + ); + assert_eq!( + binding + .arc_inputs + .as_ref() + .unwrap() + .iter() + .find(|value| value.field == StoryArcPromptField::Description) + .unwrap() + .value, + NEW + ); + assert_eq!(text(&conn, &command.block), HUMAN); + let after = crate::script_store::load_document_projection(&conn, &command.block.document_id) + .unwrap() + .unwrap(); + for (a, b) in before.segments.iter().zip(&after.segments) { + assert_eq!(a.segment, b.segment); + assert_eq!(a.blocks, b.blocks); + } + accept(&mut conn, &request).unwrap(); + assert_eq!(text(&conn, &command.block), PREVIEW); + assert_eq!(text(&conn, &c), c.text); + assert!(!impact(&conn, &command).needs_review); + metadata(&mut conn, arc, Some("Later authored direction"), None); + let next = impact(&conn, &command); + assert_eq!(next.causes.len(), 1); + assert!( + !next.causes[0] + .dependency_id + .as_str() + .contains(".arc_description_applicability.") + ); + assert_eq!(next.causes[0].input_excerpt.as_deref(), Some(NEW)); +} + +#[test] +fn clear_withdraws_entry_and_restore_aba_refuses_old_preview_without_writes() { + let (mut conn, arc, command, _) = setup(true); + edit(&mut conn, &command.block, HUMAN); + metadata(&mut conn, arc, Some(NEW), None); + let request = preview(&mut conn, &command); + let binding = script_impact_review::capture(&conn, &request.payload).unwrap(); + metadata(&mut conn, arc, Some(""), None); + assert!(!impact(&conn, &command).needs_review); + let before = rows(&conn); + assert!(accept(&mut conn, &request).is_err()); + assert_eq!(rows(&conn), before); + metadata(&mut conn, arc, Some(NEW), None); + let before = rows(&conn); + assert!(accept(&mut conn, &request).is_err()); + let mut late = request.clone(); + late.id = CommandId(uuid::Uuid::new_v4()); + late.payload.proposal_id = PropagationProposalId::new("late.arc.description").unwrap(); + let mut delayed = binding; + delayed.request = late.payload.clone(); + assert!( + script_impact_review::record_proposal(&mut conn, &late, delayed, PREVIEW.into(), 50) + .is_err() + ); + assert_eq!(rows(&conn), before); + assert_eq!(text(&conn, &command.block), HUMAN); + let fresh = preview(&mut conn, &command); + accept(&mut conn, &fresh).unwrap(); + assert!(!impact(&conn, &command).needs_review); +} + +#[test] +fn delayed_generation_and_forged_omission_receipts_refuse_atomically() { + for restore in [false, true] { + let (mut conn, arc, command, _) = setup(false); + metadata(&mut conn, arc, Some(NEW), None); + if restore { + metadata(&mut conn, arc, Some(""), None); + } + let before = rows(&conn); + assert!( + script_document_command::apply_generated_script_block( + &mut conn, + &CommandEnvelope::new(command), + 50 + ) + .is_err() + ); + assert_eq!(rows(&conn), before); + } + let (mut conn, _, command, _) = setup(false); + let original = command.arc_description_applicability.as_ref().unwrap()[0].clone(); + let other = create(&mut conn); + let wrong = StoryArcFieldInput { + arc_id: other, + ..original.clone() + }; + for inputs in [ + vec![], + vec![original.clone(), original.clone()], + vec![StoryArcFieldInput { + value: NEW.into(), + ..original.clone() + }], + vec![StoryArcFieldInput { + revision_event_id: None, + ..original + }], + vec![wrong], + ] { + let mut forged = command.clone(); + forged.arc_description_applicability = Some(inputs); + let before = rows(&conn); + assert!( + script_document_command::apply_generated_script_block( + &mut conn, + &CommandEnvelope::new(forged), + 50 + ) + .is_err() + ); + assert_eq!(rows(&conn), before); + } +} + +#[test] +fn unrelated_arc_color_and_repeated_empty_edits_do_not_create_review_or_stale_preview() { + let (mut conn, arc, command, _) = setup(true); + let other = create(&mut conn); + metadata(&mut conn, arc, Some(""), None); + metadata(&mut conn, other, Some(NEW), None); + assert!(!impact(&conn, &command).needs_review); + metadata(&mut conn, arc, Some(NEW), None); + let request = preview(&mut conn, &command); + metadata(&mut conn, arc, None, Some(Color::new(9, 8, 7))); + metadata(&mut conn, other, Some("Other direction"), None); + accept(&mut conn, &request).unwrap(); + assert!(!impact(&conn, &command).needs_review); +} + +#[test] +fn legacy_missing_applicability_and_unowned_empty_history_remain_unknown() { + for legacy in [true, false] { + let (mut conn, arc, mut command, _) = setup(false); + if legacy { + command.arc_description_applicability = None; + } else { + conn.execute("DELETE FROM object_revision_fields WHERE revision_id IN (SELECT id FROM object_revisions WHERE object_kind='story_arc' AND object_id=?1)",[arc.0.to_string()]).unwrap(); + let node = NodeId( + uuid::Uuid::parse_str(command.block.source_node_id.as_ref().unwrap()).unwrap(), + ); + let (_, fields) = arcs::capture(&conn, node, &[]).unwrap(); + command.arc_description_applicability = Some(capture(&conn, node, &fields).unwrap()); + command.arc_inputs = Some(fields); + assert!( + command.arc_description_applicability.as_ref().unwrap()[0] + .revision_event_id + .is_none() + ); + } + script_document_command::apply_generated_script_block( + &mut conn, + &CommandEnvelope::new(command.clone()), + 50, + ) + .unwrap(); + metadata(&mut conn, arc, Some(NEW), None); + assert!(!impact(&conn, &command).needs_review); + } +} + +#[test] +fn accepting_one_consumer_preserves_the_other_consumer_and_its_precise_cause() { + let (mut conn, arc, command, c) = setup(true); + conn.execute( + "INSERT INTO node_arcs(node_id,arc_id) VALUES (?1,?2)", + params![c.source_node_id, arc.0.to_string()], + ) + .unwrap(); + let mut second = command.clone(); + second.block = c.clone(); + script_document_command::apply_generated_script_block( + &mut conn, + &CommandEnvelope::new(second.clone()), + 26, + ) + .unwrap(); + edit(&mut conn, &command.block, HUMAN); + metadata(&mut conn, arc, Some(NEW), None); + let first_cause = impact(&conn, &command).causes[0].clone(); + let second_cause = impact(&conn, &second).causes[0].clone(); + assert_ne!(first_cause.dependency_id, second_cause.dependency_id); + for cause in [&first_cause, &second_cause] { + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM semantic_dependencies WHERE id=?1", + [cause.dependency_id.as_str()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + } + let request = preview(&mut conn, &command); + accept(&mut conn, &request).unwrap(); + assert!(!impact(&conn, &command).needs_review); + assert_eq!(impact(&conn, &second).causes, [second_cause]); + assert_eq!(text(&conn, &c), c.text); +} + +#[test] +fn applicability_preview_preserves_locked_manual_text_and_refuses_replacement() { + let (mut conn, arc, command, _) = setup(true); + edit(&mut conn, &command.block, HUMAN); + let doc = crate::script_store::load_document_projection(&conn, &command.block.document_id) + .unwrap() + .unwrap(); + let span = doc + .segments + .iter() + .flat_map(|row| &row.blocks) + .find(|block| block.block.id == command.block.block_id) + .unwrap() + .spans[0] + .id + .clone(); + script_document_command::apply_set_script_lock( + &mut conn, + &CommandEnvelope::new(SetScriptLockCommand { + lock_id: ScriptLockId::new("lock.arc.applicability").unwrap(), + span_id: span, + reason: "Keep authored wording".into(), + }), + 27, + ) + .unwrap(); + metadata(&mut conn, arc, Some(NEW), None); + let request = preview(&mut conn, &command); + let before = rows(&conn); + assert!(accept(&mut conn, &request).is_err()); + assert_eq!(rows(&conn), before); + assert_eq!(text(&conn, &command.block), HUMAN); +} + +#[tokio::test] +async fn exact_new_description_and_manual_target_reach_provider_failure_preserves_material() { + use eidetic_core::ai::backend::GenerateStream; + let (mut conn, arc, command, _) = setup(true); + edit(&mut conn, &command.block, HUMAN); + metadata(&mut conn, arc, Some(NEW), None); + let binding = + script_impact_review::capture(&conn, &request(&conn, &command.block).payload).unwrap(); + let before = rows(&conn); + let result = + crate::script_impact_prompt::preview_with_provider(&binding, |prompt| async move { + assert!(prompt.user.contains(NEW)); + assert!(prompt.user.contains(HUMAN)); + assert!( + prompt + .user + .contains("ORIGINAL KNOWN-EMPTY ARC DESCRIPTION APPLICABILITY") + ); + Ok(Box::pin(futures::stream::iter([ + Ok("Synthetic partial prefix".into()), + Err(eidetic_core::Error::AiBackend("Synthetic failure".into())), + ])) as GenerateStream) + }) + .await; + assert!(result.is_err()); + assert_eq!(rows(&conn), before); + assert_eq!(text(&conn, &command.block), HUMAN); +} diff --git a/crates/server/src/bible_context_scope_tests.rs b/crates/server/src/bible_context_scope_tests.rs index 4e6344fe..418e2d30 100644 --- a/crates/server/src/bible_context_scope_tests.rs +++ b/crates/server/src/bible_context_scope_tests.rs @@ -57,6 +57,9 @@ fn generation( ) -> CommandEnvelope { let inputs = inputs(conn, block); CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, arc_inputs: None, block: block.clone(), script_inputs: Some(vec![]), diff --git a/crates/server/src/bible_field_lineage_tests.rs b/crates/server/src/bible_field_lineage_tests.rs index 79a14452..269e9020 100644 --- a/crates/server/src/bible_field_lineage_tests.rs +++ b/crates/server/src/bible_field_lineage_tests.rs @@ -62,6 +62,9 @@ fn generate( inputs: Vec, ) -> CommandEnvelope { let command = CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, arc_inputs: None, target_binding: None, script_context_scope: None, @@ -269,6 +272,9 @@ fn forged_consumed_field_value_rolls_back_output_history_and_dependencies() { let mut inputs = capture_for(&conn, &b); inputs[0].value = FieldValue::Text("Forged canon".into()); let command = CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, arc_inputs: None, target_binding: None, script_context_scope: None, diff --git a/crates/server/src/bible_node_name_lineage_tests.rs b/crates/server/src/bible_node_name_lineage_tests.rs index a995a698..a2cd3fdf 100644 --- a/crates/server/src/bible_node_name_lineage_tests.rs +++ b/crates/server/src/bible_node_name_lineage_tests.rs @@ -46,6 +46,9 @@ fn generate( script_document_command::apply_generated_script_block( conn, &CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, arc_inputs: None, block: b.clone(), script_inputs: Some(vec![]), diff --git a/crates/server/src/bible_relationship_lineage_tests.rs b/crates/server/src/bible_relationship_lineage_tests.rs index e42a29f4..8f4acf66 100644 --- a/crates/server/src/bible_relationship_lineage_tests.rs +++ b/crates/server/src/bible_relationship_lineage_tests.rs @@ -57,6 +57,9 @@ fn generate( relationships: Option>, ) -> CommandEnvelope { let command = CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, arc_inputs: None, block: b.clone(), script_inputs: Some(vec![]), diff --git a/crates/server/src/canonical_generation_service_tests.rs b/crates/server/src/canonical_generation_service_tests.rs index 012d5823..b575d9dc 100644 --- a/crates/server/src/canonical_generation_service_tests.rs +++ b/crates/server/src/canonical_generation_service_tests.rs @@ -104,6 +104,7 @@ async fn fixture() -> Fixture { command_service::set_timeline_node_notes( &state, CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: b, notes: NOTES.into(), }), @@ -414,6 +415,7 @@ async fn delayed_http_after_new_scene_notes_aba_preserves_canonical_manual_scrip command_service::set_timeline_node_notes( &f.state, CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: f.node, notes: notes.into(), }), diff --git a/crates/server/src/child_plan_memory_service_tests.rs b/crates/server/src/child_plan_memory_service_tests.rs index 4b4f4368..d2390dd3 100644 --- a/crates/server/src/child_plan_memory_service_tests.rs +++ b/crates/server/src/child_plan_memory_service_tests.rs @@ -457,6 +457,7 @@ async fn descendant_notes_aba_refuses_a_pending_plan_without_overwriting_manual_ crate::command_service::set_timeline_node_notes( &fixture.state, CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: child, notes: "Original manual beat notes".into(), }), @@ -473,6 +474,7 @@ async fn descendant_notes_aba_refuses_a_pending_plan_without_overwriting_manual_ crate::command_service::set_timeline_node_notes( &fixture.state, CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: child, notes, }), diff --git a/crates/server/src/command_service.rs b/crates/server/src/command_service.rs index 861630e6..cb3a85a3 100644 --- a/crates/server/src/command_service.rs +++ b/crates/server/src/command_service.rs @@ -42,8 +42,9 @@ pub use crate::command_service_timeline::{ create_timeline_child_from_parent, create_timeline_child_from_parent_core_command, create_timeline_node, create_timeline_node_from_core_command, create_timeline_relationship, create_timeline_relationship_from_core_command, delete_timeline_node, - delete_timeline_relationship, set_timeline_node_lock, set_timeline_node_notes, - set_timeline_node_range, split_timeline_node, split_timeline_node_from_core_command, + delete_timeline_relationship, set_timeline_node_lock, set_timeline_node_name, + set_timeline_node_notes, set_timeline_node_range, split_timeline_node, + split_timeline_node_from_core_command, }; #[derive(Debug, Serialize)] @@ -190,6 +191,62 @@ pub async fn create_script_block( .await } +pub async fn remove_script_block( + state: &AppState, + command: CommandEnvelope, +) -> Result { + // Capture the request's project before waiting; a queued draft must never + // be rebound to whichever project becomes active while it waits. + let (path, session_id) = { + let active = state.project.lock(); + active.as_ref().ok_or_else(BackendError::no_project)?; + let path = state + .project_database + .active_path() + .ok_or_else(BackendError::no_project)?; + (path, *state.project_session_id.lock()) + }; + let session = state.project_session_gate.clone().lock_owned().await; + if *state.project_session_id.lock() != session_id { + return Err(BackendError::conflict( + "active project changed before screenplay removal", + )); + } + let worker = state.clone(); + crate::state::complete_project_session_work(state, session, async move { + let response = tokio::task::spawn_blocking(move || { + let mut conn = crate::sqlite::open_write_connection(&path) + .map_err(|error| BackendError::internal(error.to_string()))?; + let created_at_ms = u64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| BackendError::internal(error.to_string()))? + .as_millis(), + ) + .map_err(|error| BackendError::internal(error.to_string()))?; + let (outcome, projection) = crate::script_block_remove::apply_remove_script_block( + &mut conn, + &command, + created_at_ms, + ) + .map_err(map_script_document_error)?; + Ok::<_, BackendError>(ScriptDocumentCommandResponse { + outcome, + projection, + }) + }) + .await + .map_err(|error| { + BackendError::internal(format!("screenplay removal task failed: {error}")) + })??; + if response.outcome == RecordChangeOutcome::Recorded { + let _ = worker.events_tx.send(ServerEvent::ScriptChanged); + } + Ok(response) + }) + .await +} + pub async fn edit_script_block( state: &AppState, command: CommandEnvelope, diff --git a/crates/server/src/command_service_timeline.rs b/crates/server/src/command_service_timeline.rs index f0609897..95d3d670 100644 --- a/crates/server/src/command_service_timeline.rs +++ b/crates/server/src/command_service_timeline.rs @@ -2,8 +2,8 @@ use eidetic_core::contracts::{ ApplyTimelineChildCommand, CommandEnvelope, CreateTimelineChildFromParentCommand, CreateTimelineNodeCommand, CreateTimelineRelationshipCommand, DeleteTimelineNodeCommand, DeleteTimelineRelationshipCommand, ObjectKind, ProjectionEnvelope, SetTimelineNodeLockCommand, - SetTimelineNodeNotesCommand, SetTimelineNodeRangeCommand, SplitTimelineNodeCommand, - TimelineRenderProjection, + SetTimelineNodeNameCommand, SetTimelineNodeNotesCommand, SetTimelineNodeRangeCommand, + SplitTimelineNodeCommand, TimelineRenderProjection, }; use eidetic_core::timeline::Timeline; use rusqlite::Connection; @@ -26,6 +26,8 @@ pub use crate::command_service_timeline_requests::{ #[derive(Debug, Serialize)] pub struct TimelineCommandResponse { outcome: RecordChangeOutcome, + #[serde(skip_serializing_if = "Option::is_none")] + notes_read: Option, projection: ProjectionEnvelope, } @@ -89,6 +91,7 @@ async fn create_timeline_node_at_admission( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + notes_read: None, projection, }) }) @@ -118,6 +121,59 @@ async fn create_timeline_node_at_admission( .await } +pub async fn set_timeline_node_name( + state: &AppState, + command: CommandEnvelope, +) -> Result { + let (_session, path, project) = timeline_command_project(state).await?; + let worker = state.clone(); + crate::state::complete_project_session_work(state, _session, async move { + let state = &worker; + let response = tokio::task::spawn_blocking(move || { + let mut conn = crate::sqlite::open_write_connection(&path) + .map_err(|e| BackendError::internal(e.to_string()))?; + history_store::create_schema(&conn).map_err(map_history_error)?; + let outcome = timeline_command::record_set_timeline_node_name_history( + &mut conn, &project, &command, 0, + ) + .map_err(|error| { + let error = map_timeline_command_error(error); + // Only pre-commit domain/receipt refusals carry this marker. + // Projection/publication failures after commit remain uncertain. + match error { + BackendError::BadRequest(message) => { + BackendError::bad_request(format!("Title edit refused: {message}")) + } + BackendError::Conflict(message) => { + BackendError::conflict(format!("Title edit refused: {message}")) + } + other => other, + } + })?; + let projection = + timeline_render_projection_from_current_state(&conn, &project.timeline) + .map_err(map_timeline_command_error)?; + Ok::<_, BackendError>(TimelineCommandResponse { + outcome, + notes_read: None, + projection, + }) + }) + .await + .map_err(|error| { + BackendError::internal(format!("timeline node name command task failed: {error}")) + })??; + + if response.outcome == RecordChangeOutcome::Recorded { + let _ = state.events_tx.send(ServerEvent::TimelineChanged); + let _ = state.events_tx.send(ServerEvent::ScriptChanged); + state.trigger_save(); + } + Ok(response) + }) + .await +} + pub async fn set_timeline_node_range( state: &AppState, command: CommandEnvelope, @@ -153,6 +209,7 @@ pub async fn set_timeline_node_range( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + notes_read: None, projection, }) }) @@ -193,6 +250,7 @@ pub async fn set_timeline_node_lock( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + notes_read: None, projection, }) }) @@ -230,12 +288,26 @@ pub async fn set_timeline_node_notes( let outcome = timeline_command::record_set_timeline_node_notes_history( &mut conn, &project, &command, 0, ) - .map_err(map_timeline_command_error)?; + .map_err(|error| { + let error = map_timeline_command_error(error); + match error { + BackendError::BadRequest(message) if command.payload.expected.is_some() => { + BackendError::bad_request(format!("Notes edit refused: {message}")) + } + BackendError::Conflict(message) if command.payload.expected.is_some() => { + BackendError::conflict(format!("Notes edit refused: {message}")) + } + error => error, + } + })?; + let notes_read = crate::timeline_notes_lineage::command_receipt(&conn, &command) + .map_err(map_history_error)?; let projection = timeline_render_projection_from_current_state(&conn, &project.timeline) .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + notes_read: Some(notes_read), projection, }) }) @@ -292,6 +364,7 @@ pub async fn delete_timeline_node( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + notes_read: None, projection, }) }) @@ -342,6 +415,7 @@ pub async fn delete_timeline_relationship( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + notes_read: None, projection, }) }) @@ -390,6 +464,7 @@ pub async fn create_timeline_relationship_from_core_command( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + notes_read: None, projection, }) }) @@ -433,6 +508,7 @@ pub async fn apply_timeline_children( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + notes_read: None, projection, }) }) @@ -532,6 +608,7 @@ pub async fn split_timeline_node_from_core_command( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + notes_read: None, projection, }) }) diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 01442ffe..ee30967f 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -15,6 +15,8 @@ pub mod ai_generation_service; pub(crate) mod ai_script_context; pub mod ai_service; pub(crate) mod ai_temporal_context; +pub(crate) mod ancestor_notes_lineage; +mod arc_description_applicability; pub mod backend_error; pub mod backend_task; pub(crate) mod bible_context_scope; @@ -77,6 +79,7 @@ pub mod reference_service; pub(crate) mod revision_projection; pub(crate) mod script_block_create; pub(crate) mod script_block_edit; +pub(crate) mod script_block_remove; mod script_context_scope; pub(crate) mod script_document_command; pub(crate) mod script_fact_evidence; @@ -114,6 +117,7 @@ pub(crate) mod timeline_node_split_history; pub(crate) mod timeline_node_store; mod timeline_notes_lineage; pub(crate) mod timeline_relationship_store; +pub(crate) mod timeline_title_lineage; pub mod validation; pub(crate) mod vector_store; pub mod ydoc; diff --git a/crates/server/src/project_service.rs b/crates/server/src/project_service.rs index 8171e40d..84713d7b 100644 --- a/crates/server/src/project_service.rs +++ b/crates/server/src/project_service.rs @@ -183,15 +183,9 @@ pub async fn load_project( populate_ydoc_from_project(state, &project).await?; } - let save_path = if path - .extension() - .is_some_and(|extension| extension == "json") - { - path.with_file_name("project.db") - } else { - path - }; - replace_active_project(state, project, save_path); + // Persistence accepts SQLite content only; the filename is not an import + // instruction and must not redirect future writes to a sibling database. + replace_active_project(state, project, path); state.trigger_save(); Ok(json) }) diff --git a/crates/server/src/projection_service.rs b/crates/server/src/projection_service.rs index 5038414a..c29e27a5 100644 --- a/crates/server/src/projection_service.rs +++ b/crates/server/src/projection_service.rs @@ -383,6 +383,14 @@ fn load_selected_node_editor_at_path( .as_mut() .filter(|node| canonical_node_ids.contains(&node.node_id)) { + node.notes_read = Some( + crate::timeline_notes_lineage::capture(&tx, node.node_id).map_err(map_history_error)?, + ); + node.name_read = Some(eidetic_core::contracts::TimelineNodeNameRead { + name: node.name.clone(), + revision_event_id: timeline_node_store::latest_name_event(&tx, node.node_id, None) + .map_err(map_history_error)?, + }); node.range_read = Some(eidetic_core::contracts::TimelineNodeRangeRead { start_ms: node.start_ms, end_ms: node.end_ms, diff --git a/crates/server/src/scene_context_membership_tests.rs b/crates/server/src/scene_context_membership_tests.rs index 4d9eb5d2..0b30f1a3 100644 --- a/crates/server/src/scene_context_membership_tests.rs +++ b/crates/server/src/scene_context_membership_tests.rs @@ -83,6 +83,9 @@ fn generation( ) .unwrap(); CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, arc_inputs: None, target_binding: None, block: block.clone(), diff --git a/crates/server/src/script_block_create_impact_tests.rs b/crates/server/src/script_block_create_impact_tests.rs index a37a5011..5c117cc2 100644 --- a/crates/server/src/script_block_create_impact_tests.rs +++ b/crates/server/src/script_block_create_impact_tests.rs @@ -38,6 +38,9 @@ fn linked_fixture() -> ( script_document_command::apply_generated_script_block( &mut conn, &CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, arc_inputs: None, target_binding: None, script_context_scope: None, @@ -63,6 +66,9 @@ fn linked_fixture() -> ( script_document_command::apply_generated_script_block( &mut conn, &CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, arc_inputs: None, target_binding: None, script_context_scope: None, @@ -341,6 +347,9 @@ fn captured_append_membership_remains_valid_after_source_retimes_during_generati script_document_command::apply_generated_script_block( &mut conn, &CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, arc_inputs: None, target_binding: None, script_context_scope: None, diff --git a/crates/server/src/script_block_remove.rs b/crates/server/src/script_block_remove.rs new file mode 100644 index 00000000..3fd7b3d9 --- /dev/null +++ b/crates/server/src/script_block_remove.rs @@ -0,0 +1,151 @@ +use eidetic_core::contracts::{ + ChangeEvent, ChangeEventKind, CommandEnvelope, FieldDelta, FieldValue, ObjectKind, + ObjectRevision, ProjectionEnvelope, RemoveScriptBlockCommand, RevisionOperation, + ScriptDocumentProjection, +}; +use rusqlite::Connection; + +use crate::history_store::{self, HistoryStoreError, RecordChangeOutcome}; +use crate::script_document_command::ScriptDocumentCommandError; +use crate::{script_segment_replace, script_store}; + +const COMMAND_TYPE: &str = "script.remove_block"; + +pub(crate) fn apply_remove_script_block( + conn: &mut Connection, + command: &CommandEnvelope, + created_at_ms: u64, +) -> Result< + ( + RecordChangeOutcome, + ProjectionEnvelope, + ), + ScriptDocumentCommandError, +> { + script_store::create_schema(conn)?; + if let Some(outcome) = history_store::check_recorded_command(conn, command, COMMAND_TYPE)? { + return Ok((outcome, projection(conn, command)?)); + } + let before = projection(conn, command)?; + let existing = before + .payload + .segments + .iter() + .flat_map(|segment| &segment.blocks) + .find(|block| block.block.id == command.payload.block_id) + .ok_or_else(|| invalid("script block not found"))?; + if existing.revision_event_id != Some(command.payload.expected_revision_event_id) { + return Err(invalid("script block changed; reload before removing")); + } + if !existing.locks.is_empty() { + return Err(invalid("cannot remove a locked script block")); + } + let event = ChangeEvent::new( + command.id, + ChangeEventKind::UserEdit, + format!("remove script block {}", existing.block.id.as_str()), + ) + .with_created_at_ms(created_at_ms); + let mut revisions = vec![ + ObjectRevision::new( + ObjectKind::ScriptBlock, + existing.block.id.as_str(), + event.id, + RevisionOperation::Delete, + ) + .with_field(FieldDelta::new( + "text", + Some(FieldValue::Text(existing.block.text.clone())), + None, + )), + ObjectRevision::new( + ObjectKind::ScriptSegment, + existing.block.segment_id.as_str(), + event.id, + RevisionOperation::Update, + ) + .with_field(FieldDelta::new( + format!("block.{}", existing.block.id.as_str()), + Some(FieldValue::ObjectRef { + kind: ObjectKind::ScriptBlock, + id: existing.block.id.as_str().into(), + }), + None, + )), + ]; + revisions.extend(existing.spans.iter().map(|span| { + ObjectRevision::new( + ObjectKind::ScriptSpan, + span.id.as_str(), + event.id, + RevisionOperation::Delete, + ) + })); + let outcome = + history_store::record_change_with(conn, command, COMMAND_TYPE, &event, &revisions, |tx| { + let current = script_store::load_document_projection(tx, &command.payload.document_id)? + .ok_or_else(|| { + HistoryStoreError::InvalidValue("script document not found".into()) + })?; + let segment = current + .segments + .iter() + .find(|segment| segment.segment.id == existing.block.segment_id) + .ok_or_else(|| HistoryStoreError::InvalidValue("script block not found".into()))?; + let block = segment + .blocks + .iter() + .find(|block| block.block.id == existing.block.id) + .ok_or_else(|| HistoryStoreError::InvalidValue("script block not found".into()))?; + // Writer-lock recapture protects exact text, spans and locks. Membership + // is recaptured separately so concurrently appended blocks survive. + if !block.locks.is_empty() { + return Err(HistoryStoreError::InvalidValue( + "cannot remove a locked script block".into(), + )); + } + if block != existing { + return Err(HistoryStoreError::InvalidValue( + "script block changed; reload before removing".into(), + )); + } + let retained = segment + .blocks + .iter() + .filter(|block| block.block.id != existing.block.id) + .map(|block| block.block.id.clone()) + .collect::>(); + script_segment_replace::delete_omitted_segment_blocks_in_transaction( + tx, + &segment.segment.id, + &retained, + event.id, + )?; + tx.execute( + "UPDATE script_segments SET updated_event_id = ?1 WHERE id = ?2", + rusqlite::params![event.id.0.to_string(), segment.segment.id.as_str()], + )?; + Ok(()) + })?; + Ok((outcome, projection(conn, command)?)) +} + +fn projection( + conn: &Connection, + command: &CommandEnvelope, +) -> Result, ScriptDocumentCommandError> { + script_store::load_document_projection_envelope(conn, &command.payload.document_id)? + .ok_or_else(|| invalid("script document not found")) +} + +fn invalid(message: &str) -> ScriptDocumentCommandError { + ScriptDocumentCommandError::InvalidCommand(message.into()) +} + +#[cfg(test)] +#[path = "script_block_remove_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "script_block_remove_impact_tests.rs"] +mod impact_tests; diff --git a/crates/server/src/script_block_remove_impact_tests.rs b/crates/server/src/script_block_remove_impact_tests.rs new file mode 100644 index 00000000..1cfbc496 --- /dev/null +++ b/crates/server/src/script_block_remove_impact_tests.rs @@ -0,0 +1,102 @@ +use super::*; +use crate::{ai_script_context, script_impact_projection, script_impact_review}; +use eidetic_core::contracts::*; + +#[test] +fn removal_invalidates_old_review_and_fresh_targeted_preview_requires_explicit_acceptance() { + let (mut conn, _, a, b, c) = script_impact_review::tests::fixture(); + let old_request = script_impact_review::tests::request(&conn, &b); + let old_binding = script_impact_review::capture(&conn, &old_request.payload).unwrap(); + script_impact_review::record_proposal( + &mut conn, + &old_request, + old_binding, + "Obsolete preview".into(), + 25, + ) + .unwrap(); + let command = super::tests::removal(&conn, &a); + apply_remove_script_block(&mut conn, &command, 30).unwrap(); + let after_remove = script_store::load_document_projection(&conn, &a.document_id).unwrap(); + assert!(script_impact_review::tests::accept(&mut conn, &old_request).is_err()); + assert_eq!( + script_store::load_document_projection(&conn, &a.document_id).unwrap(), + after_remove + ); + let impact = script_impact_projection::load_impact(&conn, &b.segment_id) + .unwrap() + .unwrap(); + let cause = impact + .causes + .iter() + .find(|cause| { + cause.input + == SemanticDependencyEndpoint::ScriptBlock { + block_id: a.block_id.clone(), + } + }) + .unwrap(); + assert_eq!(cause.reason, ScriptImpactReason::Deleted); + assert!(cause.current_revision_event_id.is_none()); + assert!( + cause + .input_excerpt + .as_deref() + .unwrap() + .contains("Original A") + ); + assert!( + !script_impact_projection::load_impact(&conn, &c.segment_id) + .unwrap() + .is_some_and(|impact| impact.needs_review) + ); + let memory = ai_script_context::load_script_context( + &conn, + eidetic_core::timeline::node::NodeId( + uuid::Uuid::parse_str(b.source_node_id.as_ref().unwrap()).unwrap(), + ), + b.segment_start_ms, + b.segment_end_ms, + ) + .unwrap(); + assert!(!memory.iter().any(|input| input.block_id == a.block_id)); + let fresh_request = script_impact_review::tests::request(&conn, &b); + let binding = script_impact_review::capture(&conn, &fresh_request.payload).unwrap(); + assert!( + !binding + .script_inputs + .iter() + .any(|input| input.block_id == a.block_id) + ); + script_impact_review::record_proposal( + &mut conn, + &fresh_request, + binding, + "B no longer refers to the removed scene material.".into(), + 40, + ) + .unwrap(); + assert_eq!( + script_store::load_document_projection(&conn, &a.document_id).unwrap(), + after_remove + ); + assert_eq!( + script_impact_review::tests::accept(&mut conn, &fresh_request).unwrap(), + RecordChangeOutcome::Recorded + ); + let after = script_store::load_document_projection(&conn, &a.document_id) + .unwrap() + .unwrap(); + assert!(after.segments[0].blocks.is_empty()); + assert_eq!( + after.segments[1].blocks[0].block.text, + "B no longer refers to the removed scene material." + ); + assert_eq!(after.segments[2].blocks[0].block.text, "Original C"); + assert!( + !script_impact_projection::load_impact(&conn, &b.segment_id) + .unwrap() + .unwrap() + .needs_review + ); +} diff --git a/crates/server/src/script_block_remove_tests.rs b/crates/server/src/script_block_remove_tests.rs new file mode 100644 index 00000000..df59ba18 --- /dev/null +++ b/crates/server/src/script_block_remove_tests.rs @@ -0,0 +1,244 @@ +use super::*; +use eidetic_core::contracts::*; + +pub(super) fn removal( + conn: &Connection, + block: &SetScriptBlockCommand, +) -> CommandEnvelope { + let projection = script_store::load_document_projection(conn, &block.document_id) + .unwrap() + .unwrap(); + let current = projection + .segments + .iter() + .flat_map(|segment| &segment.blocks) + .find(|candidate| candidate.block.id == block.block_id) + .unwrap(); + CommandEnvelope::new(RemoveScriptBlockCommand { + document_id: block.document_id.clone(), + block_id: block.block_id.clone(), + expected_revision_event_id: current.revision_event_id.unwrap(), + }) +} + +#[test] +fn removal_retains_exact_history_other_blocks_and_placement_on_reopen_and_replay() { + let (mut conn, _, a, _, _) = crate::script_impact_review::tests::fixture(); + let before = script_store::load_document_projection(&conn, &a.document_id) + .unwrap() + .unwrap(); + let command = removal(&conn, &a); + let (outcome, after) = apply_remove_script_block(&mut conn, &command, 30).unwrap(); + assert_eq!(outcome, RecordChangeOutcome::Recorded); + assert_eq!(after.payload.document, before.document); + assert_eq!( + after.payload.segments[0].segment, + before.segments[0].segment + ); + assert!(after.payload.segments[0].blocks.is_empty()); + for (after, before) in after.payload.segments[1..] + .iter() + .zip(&before.segments[1..]) + { + assert_eq!(after.segment, before.segment); + assert_eq!(after.blocks, before.blocks); + } + let revisions = history_store::load_revisions_for_object( + &conn, + ObjectKind::ScriptBlock, + a.block_id.as_str(), + ) + .unwrap(); + let deleted = revisions.last().unwrap(); + assert_eq!(deleted.operation, RevisionOperation::Delete); + assert_eq!( + deleted.fields[0].old_value, + Some(FieldValue::Text( + " A now carries a blue umbrella — 雨\n\n".into() + )) + ); + assert_eq!(deleted.fields[0].new_value, None); + let retained: String = conn + .query_row( + "SELECT text FROM script_blocks WHERE id=?1 AND deleted_event_id IS NOT NULL", + [a.block_id.as_str()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(retained, " A now carries a blue umbrella — 雨\n\n"); + let spans: i64 = conn + .query_row( + "SELECT COUNT(*) FROM script_spans WHERE block_id=?1 AND deleted_event_id IS NULL", + [a.block_id.as_str()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(spans, 0); + let path = std::env::temp_dir().join(format!("eidetic-remove-{}.db", uuid::Uuid::new_v4())); + conn.execute("VACUUM INTO ?1", [path.to_str().unwrap()]) + .unwrap(); + let mut reopened = Connection::open(&path).unwrap(); + assert_eq!( + apply_remove_script_block(&mut reopened, &command, 40).unwrap(), + (RecordChangeOutcome::AlreadyRecorded, after) + ); + drop(reopened); + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn stale_aba_and_later_lock_refuse_without_any_history_write() { + let (mut conn, _, a, _, _) = crate::script_impact_review::tests::fixture(); + let command = removal(&conn, &a); + crate::script_impact_review::tests::edit(&mut conn, &a, "Changed"); + crate::script_impact_review::tests::edit( + &mut conn, + &a, + " A now carries a blue umbrella — 雨\n\n", + ); + let before = projection(&conn, &command).unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0)) + .unwrap(); + assert!( + apply_remove_script_block(&mut conn, &command, 40) + .unwrap_err() + .to_string() + .contains("changed") + ); + assert_eq!(projection(&conn, &command).unwrap(), before); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM commands", [], |r| r.get::<_, i64>(0)) + .unwrap(), + count + ); + let current = removal(&conn, &a); + crate::script_document_command::apply_set_script_lock( + &mut conn, + &CommandEnvelope::new(SetScriptLockCommand { + lock_id: ScriptLockId::new("remove.lock").unwrap(), + span_id: ScriptSpanId::new(format!("{}.span.main", a.block_id.as_str())).unwrap(), + reason: "Keep exact words".into(), + }), + 45, + ) + .unwrap(); + let locked = projection(&conn, ¤t).unwrap(); + assert!( + apply_remove_script_block(&mut conn, ¤t, 50) + .unwrap_err() + .to_string() + .contains("locked") + ); + assert_eq!(projection(&conn, ¤t).unwrap(), locked); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM commands", [], |r| r.get::<_, i64>(0)) + .unwrap(), + count + 1 + ); +} + +#[tokio::test] +async fn public_removal_replay_save_reopen_and_queued_session_retirement_preserve_canon() { + use crate::{ + command_service, project_service, sqlite, + state::{AppState, ServerEvent}, + }; + let (project, first, _) = crate::script_block_create::tests::story_project(); + let directory = crate::persistence::default_project_dir() + .join(format!("eidetic-remove-service-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&directory).unwrap(); + let directory = directory.canonicalize().unwrap(); + let path = directory.join("project.db"); + crate::persistence::save_project(&project, &path, None) + .await + .unwrap(); + let state = AppState::new().await; + project_service::replace_active_project(&state, project.clone(), path.clone()); + let created = command_service::create_script_block( + &state, + CommandEnvelope::new(CreateScriptBlockCommand { + document_id: ScriptDocumentId::new("script.document.main").unwrap(), + source_node_id: first, + expected_start_ms: 1000, + expected_end_ms: 2000, + block_kind: ScriptBlockKind::Action, + text: " Exact saved words — 雨\n\n".into(), + }), + ) + .await + .unwrap(); + let created_projection: ProjectionEnvelope = + serde_json::from_value(serde_json::to_value(&created).unwrap()["projection"].clone()) + .unwrap(); + let block = &created_projection.payload.segments[0].blocks[0]; + let command = CommandEnvelope::new(RemoveScriptBlockCommand { + document_id: created_projection.payload.document.id.clone(), + block_id: block.block.id.clone(), + expected_revision_event_id: block.revision_event_id.unwrap(), + }); + let held = state.project_session_gate.lock().await; + let mut queued = Box::pin(command_service::remove_script_block( + &state, + command.clone(), + )); + use std::future::Future; + assert!( + queued + .as_mut() + .poll(&mut std::task::Context::from_waker( + futures::task::noop_waker_ref() + )) + .is_pending() + ); + project_service::replace_active_project(&state, project, path.clone()); + drop(held); + assert!(matches!( + queued.await.unwrap_err(), + crate::backend_error::BackendError::Conflict(_) + )); + let conn = sqlite::open_write_connection(&path).unwrap(); + assert_eq!( + script_store::load_document_projection_envelope(&conn, &command.payload.document_id) + .unwrap() + .unwrap(), + created_projection + ); + let mut events = state.events_tx.subscribe(); + let removed = command_service::remove_script_block(&state, command.clone()) + .await + .unwrap(); + assert!(matches!( + events.try_recv().unwrap(), + ServerEvent::ScriptChanged + )); + let replay = command_service::remove_script_block(&state, command.clone()) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(replay).unwrap()["outcome"], + "already_recorded" + ); + assert!(events.try_recv().is_err()); + project_service::save_project(&state, project_service::SaveProjectRequest { path: None }) + .await + .unwrap(); + project_service::load_project( + &state, + project_service::LoadProjectRequest { + path: path.display().to_string(), + }, + ) + .await + .unwrap(); + let replay = command_service::remove_script_block(&state, command) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(replay).unwrap()["projection"], + serde_json::to_value(removed).unwrap()["projection"] + ); + state.shutdown_tasks_async().await; + drop(conn); + std::fs::remove_dir_all(directory).unwrap(); +} diff --git a/crates/server/src/script_document_command.rs b/crates/server/src/script_document_command.rs index 3f9b95fa..b3f4655f 100644 --- a/crates/server/src/script_document_command.rs +++ b/crates/server/src/script_document_command.rs @@ -123,6 +123,9 @@ fn apply_script_block( |tx| { if let Some(generation) = generation { crate::script_generation_target::validate(tx, generation)?; + crate::ancestor_notes_lineage::validate_admission(tx, generation)?; + crate::timeline_title_lineage::validate_admission(tx, generation)?; + crate::arc_description_applicability::validate_admission(tx, generation)?; let current = script_store::load_document_projection(tx, &payload.document_id)?; validate_locked_spans(current.as_ref(), payload) .map_err(|error| HistoryStoreError::InvalidValue(error.to_string()))?; diff --git a/crates/server/src/script_fact_proposal_tests.rs b/crates/server/src/script_fact_proposal_tests.rs index e1832df7..50104fa1 100644 --- a/crates/server/src/script_fact_proposal_tests.rs +++ b/crates/server/src/script_fact_proposal_tests.rs @@ -112,6 +112,9 @@ pub(crate) fn fixture() -> ( crate::script_document_command::apply_generated_script_block( &mut conn, &CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, block: block.clone(), script_inputs: Some(vec![]), bible_inputs: Some(inputs.clone()), diff --git a/crates/server/src/script_generation_lineage.rs b/crates/server/src/script_generation_lineage.rs index f62be479..de681a85 100644 --- a/crates/server/src/script_generation_lineage.rs +++ b/crates/server/src/script_generation_lineage.rs @@ -91,6 +91,27 @@ pub(crate) fn dependencies( dependencies.push(dependency); } } + dependencies.extend(crate::timeline_title_lineage::dependencies( + conn, + command.timeline_title_inputs.as_deref(), + &command.block.segment_id, + event, + created_at_ms, + )?); + dependencies.extend(crate::ancestor_notes_lineage::dependencies( + conn, + command.ancestor_notes_inputs.as_deref(), + &command.block.segment_id, + event, + created_at_ms, + )?); + dependencies.extend(crate::arc_description_applicability::dependencies( + conn, + command.arc_description_applicability.as_deref(), + &command.block.segment_id, + event, + created_at_ms, + )?); let mut arc_seen = std::collections::BTreeSet::new(); for input in command.arc_inputs.iter().flatten() { if !arc_seen.insert((input.arc_id.0.to_string(), input.field)) { @@ -225,7 +246,22 @@ pub(crate) fn record_in_transaction( dependencies: &[SemanticDependency], ) -> Result<(), HistoryStoreError> { crate::script_context_scope::validate(tx, command)?; + crate::timeline_title_lineage::validate_inputs( + tx, + command.timeline_title_inputs.as_deref().unwrap_or_default(), + )?; + crate::arc_description_applicability::validate_inputs( + tx, + command + .arc_description_applicability + .as_deref() + .unwrap_or_default(), + )?; crate::bible_context_scope::validate(tx, command)?; + crate::ancestor_notes_lineage::validate_inputs( + tx, + command.ancestor_notes_inputs.as_deref().unwrap_or_default(), + )?; for input in command.script_inputs.iter().flatten() { if input.document_id != command.block.document_id { return Err(HistoryStoreError::InvalidValue( diff --git a/crates/server/src/script_generation_lineage_tests.rs b/crates/server/src/script_generation_lineage_tests.rs index 5d03460f..783e4c92 100644 --- a/crates/server/src/script_generation_lineage_tests.rs +++ b/crates/server/src/script_generation_lineage_tests.rs @@ -62,6 +62,9 @@ fn generation( block.text = text.into(); block.span_provenance = ScriptSpanProvenance::AiGenerated; CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, arc_inputs: None, target_binding: None, script_context_scope: None, diff --git a/crates/server/src/script_generation_target_tests.rs b/crates/server/src/script_generation_target_tests.rs index b102d104..1b9ac1fd 100644 --- a/crates/server/src/script_generation_target_tests.rs +++ b/crates/server/src/script_generation_target_tests.rs @@ -16,6 +16,9 @@ pub(crate) fn fixture() -> ( block.span_provenance = ScriptSpanProvenance::AiGenerated; block.text = "Synthetic new canonical scene.\n\n".into(); let command = CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, arc_inputs: None, block, script_inputs: None, @@ -80,6 +83,7 @@ fn manual_timeline_notes_edit_marks_its_saved_generated_screenplay_for_review() &mut conn, &project, &CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id, notes: notes.into(), }), @@ -209,6 +213,7 @@ fn notes_edit_and_restore_advances_target_revision_and_refuses_delayed_output() &mut conn, &project, &CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id, notes: notes.clone(), }), diff --git a/crates/server/src/script_impact_projection.rs b/crates/server/src/script_impact_projection.rs index accdcbbf..339fe621 100644 --- a/crates/server/src/script_impact_projection.rs +++ b/crates/server/src/script_impact_projection.rs @@ -55,6 +55,13 @@ pub(crate) fn load_impact( )?; let mut causes = Vec::new(); for dependency in dependencies { + if dependency + .id + .as_str() + .contains(".arc_description_applicability.") + { + continue; + } let Some(ref binding) = dependency.revision_binding else { continue; }; @@ -146,6 +153,19 @@ pub(crate) fn load_impact( { causes.push(cause); } + causes.extend(crate::timeline_title_lineage::impact( + conn, + generation_event_id, + )?); + causes.extend(crate::ancestor_notes_lineage::impact( + conn, + generation_event_id, + )?); + causes.extend(crate::arc_description_applicability::impact( + conn, + generation_event_id, + segment, + )?); Ok(Some(ScriptImpactProjection { generation_event_id, output_block_id: Some( diff --git a/crates/server/src/script_impact_prompt.rs b/crates/server/src/script_impact_prompt.rs index 9e94f784..c2d5e328 100644 --- a/crates/server/src/script_impact_prompt.rs +++ b/crates/server/src/script_impact_prompt.rs @@ -27,12 +27,37 @@ where user: String::new(), }; append_script_context(&mut prompt.user, &binding.script_inputs); + crate::timeline_title_lineage::append_prompt(&mut prompt.user, binding); if let Some(input) = &binding.timeline_notes_current { prompt.user.push_str(&format!( "\nCURRENT SELECTED CLIP NOTES:\n{}\n", input.notes )); } + for input in binding.ancestor_notes_current.iter().flatten() { + prompt.user.push_str(&format!( + "\nCURRENT CONSUMED ANCESTOR NOTES ({}):\n{}\n", + input.node_id.0, + if input.notes.is_empty() { + "(cleared)" + } else { + &input.notes + }, + )); + } + for (node, _) in binding.ancestor_notes_absence_revisions.iter().flatten() { + prompt.user.push_str(&format!( + "\nPREVIOUSLY CONSUMED ANCESTOR REMOVED: {}\n", + node.0 + )); + } + for input in binding + .arc_description_applicability_previous + .iter() + .flatten() + { + prompt.user.push_str(&format!("\nORIGINAL KNOWN-EMPTY ARC DESCRIPTION APPLICABILITY ({}): description prose was not supplied; field revision {}\n", input.arc_id.0, input.revision_event_id.map(|event| event.0.to_string()).unwrap_or_else(|| "unknown".into()))); + } crate::story_arc_lineage::append_prompt( &mut prompt.user, binding.arc_inputs.as_deref().unwrap_or_default(), diff --git a/crates/server/src/script_impact_review.rs b/crates/server/src/script_impact_review.rs index 17139e1d..1aaea3b4 100644 --- a/crates/server/src/script_impact_review.rs +++ b/crates/server/src/script_impact_review.rs @@ -182,7 +182,23 @@ fn capture_in_snapshot( request.generation_event_id, &request.segment_id, )?; + let titles = crate::timeline_title_lineage::preview_inputs(conn, request.generation_event_id)?; + let ancestors = + crate::ancestor_notes_lineage::preview_inputs(conn, request.generation_event_id)?; + let arc_description_applicability_previous = + crate::arc_description_applicability::recorded(conn, request.generation_event_id)?; + let arc_description_applicability_current = Some( + crate::arc_description_applicability::capture(conn, node_id, &arcs.current)?, + ); Ok(ScriptImpactProposalBinding { + arc_description_applicability_previous, + arc_description_applicability_current, + timeline_title_previous: titles.previous, + timeline_title_current: titles.current, + timeline_title_absence_revisions: titles.absent, + ancestor_notes_previous: ancestors.previous, + ancestor_notes_current: ancestors.current, + ancestor_notes_absence_revisions: ancestors.absent, timeline_notes_previous: crate::timeline_notes_lineage::recorded( conn, request.generation_event_id, @@ -365,6 +381,9 @@ pub(crate) fn accept_bound_proposal( }; script_document_command::validate_locked_spans(Some(&document), &write)?; let generated = GenerateScriptBlockCommand { + arc_description_applicability: binding.arc_description_applicability_current.clone(), + timeline_title_inputs: binding.timeline_title_current.clone(), + ancestor_notes_inputs: binding.ancestor_notes_current.clone(), arc_inputs: binding.arc_inputs.clone(), target_binding: None, block: write.clone(), diff --git a/crates/server/src/script_impact_review_tests.rs b/crates/server/src/script_impact_review_tests.rs index 2e579eae..f9866287 100644 --- a/crates/server/src/script_impact_review_tests.rs +++ b/crates/server/src/script_impact_review_tests.rs @@ -49,6 +49,9 @@ pub(crate) fn fixture() -> ( script_document_command::apply_generated_script_block( &mut conn, &CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, arc_inputs: None, target_binding: None, script_context_scope: None, diff --git a/crates/server/src/state_autosave_custody_tests.rs b/crates/server/src/state_autosave_custody_tests.rs index 38621f81..7e1acebc 100644 --- a/crates/server/src/state_autosave_custody_tests.rs +++ b/crates/server/src/state_autosave_custody_tests.rs @@ -35,6 +35,7 @@ async fn autosave_custody_holds_snapshot_and_document_until_persistence_finishes &mut conn, project, &CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: node, notes: notes.into(), }), diff --git a/crates/server/src/story_arc_lineage.rs b/crates/server/src/story_arc_lineage.rs index 669f59ae..fc9c0420 100644 --- a/crates/server/src/story_arc_lineage.rs +++ b/crates/server/src/story_arc_lineage.rs @@ -213,7 +213,7 @@ pub(crate) fn preview_inputs( segment: &ScriptSegmentId, ) -> Result { let previous = recorded(conn, generation)?; - let (_, inputs) = capture(conn, node, previous.as_deref().unwrap_or_default())?; + let (arcs, inputs) = capture(conn, node, previous.as_deref().unwrap_or_default())?; let dependencies = crate::semantic_dependency_store::load_semantic_dependency_projection( conn, &SemanticDependencyFilter { @@ -241,10 +241,9 @@ pub(crate) fn preview_inputs( continue; }; if crate::story_arc_store::load_arc(conn, &arc_id)?.is_some() { - if !inputs - .iter() - .any(|input| input.arc_id == arc_id && input.field == field) - { + // Known-empty applicability has a dependency but supplies no prose. + // Check actual tags, independently of which fields capture supplied. + if !arcs.iter().any(|arc| arc.id == arc_id) { return Err(HistoryStoreError::InvalidValue("Story arc review source is outside current tags; restore its context before previewing".into())); } } else if seen.insert(arc_id.0.to_string()) { diff --git a/crates/server/src/story_arc_lineage_tests.rs b/crates/server/src/story_arc_lineage_tests.rs index 7619fd09..84ea0708 100644 --- a/crates/server/src/story_arc_lineage_tests.rs +++ b/crates/server/src/story_arc_lineage_tests.rs @@ -69,6 +69,9 @@ fn generate( inputs: Option>, ) -> CommandEnvelope { let command = CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, arc_inputs: inputs, block: b.clone(), script_inputs: Some(vec![]), @@ -315,6 +318,9 @@ fn forged_field_value_refuses_generation_atomically_and_canonical_history_drift_ let mut captured = inputs(&conn, &b); captured[0].value = "Forged name".into(); let command = CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, arc_inputs: Some(captured), block: b.clone(), script_inputs: Some(vec![]), diff --git a/crates/server/src/timeline_command.rs b/crates/server/src/timeline_command.rs index 5e18314e..37cb3434 100644 --- a/crates/server/src/timeline_command.rs +++ b/crates/server/src/timeline_command.rs @@ -21,7 +21,8 @@ pub(crate) use crate::timeline_children_history::record_apply_timeline_children_ pub(crate) use crate::timeline_command_history::{ record_create_timeline_node_history, record_create_timeline_relationship_history, record_delete_timeline_relationship_history, record_set_timeline_node_lock_history, - record_set_timeline_node_notes_history, record_set_timeline_node_range_history, + record_set_timeline_node_name_history, record_set_timeline_node_notes_history, + record_set_timeline_node_range_history, }; pub(crate) use crate::timeline_node_delete_history::record_delete_timeline_node_history; pub(crate) use crate::timeline_node_split_history::record_split_timeline_node_history; diff --git a/crates/server/src/timeline_command_guard_tests.rs b/crates/server/src/timeline_command_guard_tests.rs index 2bdeae29..c771dab9 100644 --- a/crates/server/src/timeline_command_guard_tests.rs +++ b/crates/server/src/timeline_command_guard_tests.rs @@ -51,6 +51,7 @@ fn fixture() -> (Connection, Project) { fn notes_command(project: &Project) -> CommandEnvelope { CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: project.timeline.nodes[0].id, notes: "Intervening human edit".into(), }) diff --git a/crates/server/src/timeline_command_history.rs b/crates/server/src/timeline_command_history.rs index fc2cb314..dbc5738d 100644 --- a/crates/server/src/timeline_command_history.rs +++ b/crates/server/src/timeline_command_history.rs @@ -3,7 +3,8 @@ use eidetic_core::contracts::{ ChangeEvent, ChangeEventId, ChangeEventKind, CommandEnvelope, CreateTimelineNodeCommand, CreateTimelineRelationshipCommand, DeleteTimelineRelationshipCommand, FieldDelta, FieldValue, ObjectKind, ObjectRevision, RevisionOperation, SetTimelineNodeLockCommand, - SetTimelineNodeNotesCommand, SetTimelineNodeRangeCommand, + SetTimelineNodeNameCommand, SetTimelineNodeNotesCommand, SetTimelineNodeRangeCommand, + TimelineNodeNameRead, }; use eidetic_core::timeline::node::{ContentStatus, StoryLevel, StoryNode}; use eidetic_core::timeline::timing::TimeRange; @@ -17,6 +18,76 @@ use crate::timeline_command_history_codec::{ use crate::timeline_node_store; use crate::timeline_relationship_store; +pub(crate) fn record_set_timeline_node_name_history( + conn: &mut Connection, + project: &Project, + command: &CommandEnvelope, + created_at_ms: u64, +) -> Result { + if let Some(outcome) = + history_store::check_recorded_command(conn, command, "timeline.node_name")? + { + return Ok(outcome); + } + let name = &command.payload.name; + if name.trim().is_empty() || name.chars().count() > 1024 || name.chars().any(|c| c.is_control()) + { + return Err(crate::history_store::HistoryStoreError::InvalidValue( + "Enter a title of 1–1024 characters without control characters".into(), + ) + .into()); + } + let node = project.timeline.node(command.payload.node_id)?; + let event = ChangeEvent::new( + command.id, + ChangeEventKind::UserEdit, + format!("rename timeline node {}", node.name), + ) + .with_created_at_ms(created_at_ms); + let revision = ObjectRevision::new( + ObjectKind::TimelineNode, + node.id.0.to_string(), + event.id, + RevisionOperation::Update, + ) + .with_field(FieldDelta::new( + "name", + Some(FieldValue::Text(node.name.clone())), + Some(FieldValue::Text(name.clone())), + )); + let mut next = project.timeline.clone(); + next.node_mut(node.id)?.name = name.clone(); + Ok(history_store::record_change_with( + conn, + command, + "timeline.node_name", + &event, + &[revision], + |tx| { + crate::timeline_command_guard::validate_current_timeline(tx, &project.timeline)?; + let current = TimelineNodeNameRead { + name: node.name.clone(), + revision_event_id: timeline_node_store::latest_name_event( + tx, + node.id, + Some(event.id), + )?, + }; + if current != command.payload.expected { + return Err(history_store::HistoryStoreError::InvalidValue( + "title changed; reload the current title before saving".into(), + )); + } + if node.name == *name { + return Err(history_store::HistoryStoreError::InvalidValue( + "title is unchanged".into(), + )); + } + timeline_node_store::upsert_nodes_in_transaction(tx, &next.nodes) + }, + )?) +} + pub(crate) fn record_set_timeline_node_range_history( conn: &mut Connection, project: &Project, @@ -215,6 +286,25 @@ pub(crate) fn record_set_timeline_node_notes_history( &[revision], |tx| { crate::timeline_command_guard::validate_current_timeline(tx, &project.timeline)?; + if let Some(expected) = &command.payload.expected { + let current = + crate::timeline_notes_lineage::capture_excluding(tx, node.id, Some(event.id))?; + if expected != ¤t { + return Err(crate::history_store::HistoryStoreError::InvalidValue( + "Notes changed since this draft was read; keep the draft and read current Notes".into(), + )); + } + if node.locked { + return Err(crate::history_store::HistoryStoreError::InvalidValue( + "Notes are locked".into(), + )); + } + if command.payload.notes == current.notes { + return Err(crate::history_store::HistoryStoreError::InvalidValue( + "Notes are unchanged".into(), + )); + } + } timeline_node_store::upsert_nodes_in_transaction(tx, &next_timeline.nodes) }, )?) @@ -543,3 +633,11 @@ fn validate_create_timeline_node( #[cfg(test)] #[path = "timeline_range_history_tests.rs"] mod range_tests; + +#[cfg(test)] +#[path = "timeline_title_command_tests.rs"] +mod title_tests; + +#[cfg(test)] +#[path = "timeline_notes_draft_command_tests.rs"] +mod timeline_notes_draft_command_tests; diff --git a/crates/server/src/timeline_command_tests.rs b/crates/server/src/timeline_command_tests.rs index 6116cc28..016bc3b7 100644 --- a/crates/server/src/timeline_command_tests.rs +++ b/crates/server/src/timeline_command_tests.rs @@ -176,6 +176,7 @@ fn set_timeline_node_notes_updates_projection_status() { let command = CommandEnvelope { id: CommandId::new(), payload: SetTimelineNodeNotesCommand { + expected: None, node_id, notes: "New outline".to_string(), }, @@ -202,6 +203,7 @@ fn set_timeline_node_notes_rejects_unknown_node() { let command = CommandEnvelope { id: CommandId::new(), payload: SetTimelineNodeNotesCommand { + expected: None, node_id: NodeId::new(), notes: "New outline".to_string(), }, diff --git a/crates/server/src/timeline_node_store.rs b/crates/server/src/timeline_node_store.rs index 29a7afe8..228184f0 100644 --- a/crates/server/src/timeline_node_store.rs +++ b/crates/server/src/timeline_node_store.rs @@ -384,6 +384,31 @@ pub(crate) fn latest_node_event( .transpose() } +/// Owned title clock, excluding the command currently being recorded. +pub(crate) fn latest_name_event( + conn: &Connection, + node_id: NodeId, + exclude: Option, +) -> Result, HistoryStoreError> { + let event: Option = conn.query_row( + "SELECT r.change_event_id FROM object_revisions r JOIN change_events e ON e.id=r.change_event_id + WHERE r.object_kind='timeline_node' AND r.object_id=?1 + AND (?2 IS NULL OR r.change_event_id != ?2) + AND (r.operation='delete' OR EXISTS(SELECT 1 FROM object_revision_fields f + WHERE f.revision_id=r.id AND f.field_key='name')) + ORDER BY e.rowid DESC,r.rowid DESC LIMIT 1", + params![node_id.0.to_string(), exclude.map(|event| event.0.to_string())], + |row| row.get(0), + ).optional()?; + event + .map(|event| { + Uuid::parse_str(&event) + .map(eidetic_core::contracts::ChangeEventId) + .map_err(|error| HistoryStoreError::InvalidId(error.to_string())) + }) + .transpose() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/server/src/timeline_notes_draft_command_tests.rs b/crates/server/src/timeline_notes_draft_command_tests.rs new file mode 100644 index 00000000..93e2f87f --- /dev/null +++ b/crates/server/src/timeline_notes_draft_command_tests.rs @@ -0,0 +1,262 @@ +use super::*; +use crate::{timeline_node_store, timeline_notes_lineage}; +use eidetic_core::{Project, Template}; + +fn fixture() -> (Connection, Project) { + let project = Template::MultiCam.build_project("Notes custody"); + let mut conn = Connection::open_in_memory().unwrap(); + history_store::create_schema(&conn).unwrap(); + conn.execute_batch("CREATE TABLE project (id INTEGER PRIMARY KEY, total_duration_ms INTEGER);") + .unwrap(); + conn.execute( + "INSERT INTO project VALUES (1, ?1)", + [project.timeline.total_duration_ms], + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + timeline_node_store::upsert_nodes_in_transaction(&tx, &project.timeline.nodes).unwrap(); + timeline_node_store::replace_node_arcs_in_transaction(&tx, &project.timeline.node_arcs) + .unwrap(); + crate::timeline_relationship_store::upsert_relationships_in_transaction( + &tx, + &project.timeline.relationships, + ) + .unwrap(); + tx.commit().unwrap(); + (conn, project) +} +fn command( + conn: &Connection, + project: &Project, + text: &str, +) -> CommandEnvelope { + let node_id = project.timeline.nodes[0].id; + CommandEnvelope::new(SetTimelineNodeNotesCommand { + node_id, + notes: text.into(), + expected: Some(timeline_notes_lineage::capture(conn, node_id).unwrap()), + }) +} +fn save( + conn: &mut Connection, + project: &mut Project, + command: &CommandEnvelope, +) { + assert_eq!( + record_set_timeline_node_notes_history(conn, project, command, 1).unwrap(), + RecordChangeOutcome::Recorded + ); + reload(conn, project); +} +fn reload(conn: &Connection, project: &mut Project) { + let loaded = timeline_node_store::load_nodes(conn).unwrap(); + for node in &mut project.timeline.nodes { + *node = loaded + .iter() + .find(|current| current.id == node.id) + .unwrap() + .clone(); + } +} + +#[test] +fn guarded_notes_preserve_exact_text_and_replay_original_owned_receipt_after_another_save() { + let (mut conn, mut project) = fixture(); + let first = command(&conn, &project, " Mara reveals the witness — 雨.\n\n "); + assert_eq!( + first.payload.expected.as_ref().unwrap().revision_event_id, + None + ); + let other_nodes = project.timeline.nodes[1..].to_vec(); + save(&mut conn, &mut project, &first); + let receipt = timeline_notes_lineage::command_receipt(&conn, &first).unwrap(); + assert_eq!(receipt.notes, first.payload.notes); + assert!(receipt.revision_event_id.is_some()); + assert_eq!( + serde_json::to_value(&project.timeline.nodes[1..]).unwrap(), + serde_json::to_value(&other_nodes).unwrap() + ); + let second = command(&conn, &project, "Concurrent writer"); + save(&mut conn, &mut project, &second); + let lock = CommandEnvelope::new(SetTimelineNodeLockCommand { + node_id: first.payload.node_id, + locked: true, + }); + record_set_timeline_node_lock_history(&mut conn, &project, &lock, 8).unwrap(); + reload(&conn, &mut project); + let before = timeline_notes_lineage::tests::logical_rows(&conn); + assert_eq!( + record_set_timeline_node_notes_history(&mut conn, &project, &first, 9).unwrap(), + RecordChangeOutcome::AlreadyRecorded + ); + assert_eq!(timeline_notes_lineage::tests::logical_rows(&conn), before); + assert_eq!( + timeline_notes_lineage::command_receipt(&conn, &first).unwrap(), + receipt + ); + assert_eq!( + timeline_notes_lineage::capture(&conn, first.payload.node_id) + .unwrap() + .notes, + second.payload.notes + ); + let mut collision = first; + collision.payload.notes = "Altered retry".into(); + assert!(record_set_timeline_node_notes_history(&mut conn, &project, &collision, 10).is_err()); + assert_eq!(timeline_notes_lineage::tests::logical_rows(&conn), before); +} +#[test] +fn stale_baseline_aba_mismatched_node_locked_and_unchanged_notes_leave_every_table_exact() { + let (mut conn, mut project) = fixture(); + let stale = command(&conn, &project, "Unsaved intent"); + let initial = stale.payload.expected.as_ref().unwrap().notes.clone(); + let first = command(&conn, &project, "Temporary"); + save(&mut conn, &mut project, &first); + let restore = command(&conn, &project, &initial); + save(&mut conn, &mut project, &restore); + let mut wrong_node = command(&conn, &project, "Another intent"); + wrong_node.payload.expected.as_mut().unwrap().node_id = project.timeline.nodes[1].id; + let unchanged = command(&conn, &project, &initial); + for candidate in [&stale, &wrong_node, &unchanged] { + let before = timeline_notes_lineage::tests::logical_rows(&conn); + assert!(record_set_timeline_node_notes_history(&mut conn, &project, candidate, 9).is_err()); + assert_eq!(timeline_notes_lineage::tests::logical_rows(&conn), before); + } + let lock = CommandEnvelope::new(SetTimelineNodeLockCommand { + node_id: stale.payload.node_id, + locked: true, + }); + record_set_timeline_node_lock_history(&mut conn, &project, &lock, 10).unwrap(); + reload(&conn, &mut project); + let locked = command(&conn, &project, "Locked intent"); + let before = timeline_notes_lineage::tests::logical_rows(&conn); + assert!( + record_set_timeline_node_notes_history(&mut conn, &project, &locked, 11) + .unwrap_err() + .to_string() + .contains("locked") + ); + assert_eq!(timeline_notes_lineage::tests::logical_rows(&conn), before); +} +#[test] +fn legacy_notes_wire_omits_guard_and_guarded_wire_reuses_notes_lineage_input() { + let (conn, project) = fixture(); + let mut cmd = command(&conn, &project, " exact\n"); + let guarded = serde_json::to_value(&cmd.payload).unwrap(); + assert_eq!( + guarded["expected"]["revision_event_id"], + serde_json::Value::Null + ); + assert_eq!( + serde_json::from_value::(guarded).unwrap(), + cmd.payload + ); + cmd.payload.expected = None; + let legacy = serde_json::to_value(&cmd.payload).unwrap(); + assert!(legacy.get("expected").is_none()); + assert_eq!( + serde_json::from_value::(legacy) + .unwrap() + .expected, + None + ); +} + +#[tokio::test] +async fn public_guarded_notes_return_original_ack_refuse_stale_and_survive_save_reopen() { + use crate::{ + command_service, persistence, project_service, projection_service, state::AppState, + }; + let state = AppState::new().await; + let project = Template::MultiCam.build_project("Notes service custody"); + let node = project.timeline.nodes[0].id; + let path = persistence::default_project_dir() + .join(format!("notes-custody-{}.db", uuid::Uuid::new_v4())); + persistence::save_project(&project, &path, None) + .await + .unwrap(); + project_service::replace_active_project(&state, project, path.clone()); + let query = || projection_service::SelectedNodeEditorProjectionRequest { + node_id: Some(node), + }; + let original = projection_service::selected_node_editor_projection(&state, query()) + .await + .unwrap() + .payload + .node + .unwrap() + .notes_read + .unwrap(); + let first = CommandEnvelope::new(SetTimelineNodeNotesCommand { + node_id: node, + notes: " Mara reveals the witness — 雨.\n\n ".into(), + expected: Some(original.clone()), + }); + let ack = serde_json::to_value( + command_service::set_timeline_node_notes(&state, first.clone()) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(ack["notes_read"]["notes"], first.payload.notes); + let current = projection_service::selected_node_editor_projection(&state, query()) + .await + .unwrap() + .payload + .node + .unwrap() + .notes_read + .unwrap(); + assert_eq!(serde_json::to_value(¤t).unwrap(), ack["notes_read"]); + let stale = CommandEnvelope::new(SetTimelineNodeNotesCommand { + node_id: node, + notes: "Unaccepted stale intent".into(), + expected: Some(original), + }); + let before = crate::timeline_postcommit_custody_tests::history(&path); + let error = command_service::set_timeline_node_notes(&state, stale) + .await + .unwrap_err(); + assert!(matches!( + error, + crate::backend_error::BackendError::Conflict(_) + )); + assert!(error.to_string().starts_with("Notes edit refused: ")); + assert_eq!( + crate::timeline_postcommit_custody_tests::history(&path), + before + ); + let second = CommandEnvelope::new(SetTimelineNodeNotesCommand { + node_id: node, + notes: "Concurrent saved Notes".into(), + expected: Some(current), + }); + command_service::set_timeline_node_notes(&state, second) + .await + .unwrap(); + let before = crate::timeline_postcommit_custody_tests::history(&path); + let retry = serde_json::to_value( + command_service::set_timeline_node_notes(&state, first) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(retry["outcome"], "already_recorded"); + assert_eq!(retry["notes_read"], ack["notes_read"]); + assert_eq!( + crate::timeline_postcommit_custody_tests::history(&path), + before + ); + project_service::save_project(&state, project_service::SaveProjectRequest { path: None }) + .await + .unwrap(); + let (reopened, _) = persistence::load_project(&path).await.unwrap(); + assert_eq!( + reopened.timeline.node(node).unwrap().content.notes, + "Concurrent saved Notes" + ); + state.shutdown_tasks(); + for suffix in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{suffix}", path.display())); + } +} diff --git a/crates/server/src/timeline_notes_lineage.rs b/crates/server/src/timeline_notes_lineage.rs index 9a3e59fa..24902aa2 100644 --- a/crates/server/src/timeline_notes_lineage.rs +++ b/crates/server/src/timeline_notes_lineage.rs @@ -11,19 +11,29 @@ fn invalid() -> HistoryStoreError { ) } -fn owned_revision( +pub(crate) fn owned_revision( conn: &Connection, node: NodeId, through: Option, +) -> Result, HistoryStoreError> { + owned_revision_excluding(conn, node, through, None) +} + +fn owned_revision_excluding( + conn: &Connection, + node: NodeId, + through: Option, + exclude: Option, ) -> Result, HistoryStoreError> { let event: Option = conn.query_row( "SELECT r.change_event_id FROM object_revisions r JOIN change_events e ON e.id=r.change_event_id WHERE r.object_kind='timeline_node' AND r.object_id=?1 AND (r.operation='delete' OR EXISTS(SELECT 1 FROM object_revision_fields f WHERE f.revision_id=r.id AND f.field_key='notes')) + AND (?3 IS NULL OR e.id != ?3) AND (?2 IS NULL OR e.rowid <= (SELECT rowid FROM change_events WHERE id=?2)) ORDER BY e.rowid DESC,r.rowid DESC LIMIT 1", - params![node.0.to_string(), through.map(|event| event.0.to_string())], |row| row.get(0) + params![node.0.to_string(), through.map(|event| event.0.to_string()), exclude.map(|event| event.0.to_string())], |row| row.get(0) ).optional()?; event .map(|event| { @@ -34,7 +44,7 @@ fn owned_revision( .transpose() } -fn validate_history( +pub(crate) fn validate_history( conn: &Connection, input: &TimelineNotesInput, ) -> Result<(), HistoryStoreError> { @@ -94,6 +104,15 @@ pub(crate) fn from_target( pub(crate) fn capture( conn: &Connection, node: NodeId, +) -> Result { + capture_excluding(conn, node, None) +} + +/// Writer guards exclude their tentative event while sharing the owned Notes clock. +pub(crate) fn capture_excluding( + conn: &Connection, + node: NodeId, + exclude: Option, ) -> Result { let node = crate::timeline_node_store::load_node_ancestor_stack(conn, node)? .into_iter() @@ -102,7 +121,30 @@ pub(crate) fn capture( let input = TimelineNotesInput { node_id: node.id, notes: node.content.notes, - revision_event_id: owned_revision(conn, node.id, None)?, + revision_event_id: owned_revision_excluding(conn, node.id, None, exclude)?, + }; + validate_history(conn, &input)?; + Ok(input) +} + +/// Recover this command's original owned receipt, including an acknowledgement retry. +/// A later Notes edit never becomes this save's acknowledgement. +pub(crate) fn command_receipt( + conn: &Connection, + command: &CommandEnvelope, +) -> Result { + let event: String = conn.query_row( + "SELECT id FROM change_events WHERE command_id=?1", + [command.id.0.to_string()], + |row| row.get(0), + )?; + let input = TimelineNotesInput { + node_id: command.payload.node_id, + notes: command.payload.notes.clone(), + revision_event_id: Some(ChangeEventId( + uuid::Uuid::parse_str(&event) + .map_err(|error| HistoryStoreError::InvalidId(error.to_string()))?, + )), }; validate_history(conn, &input)?; Ok(input) @@ -190,6 +232,15 @@ pub(crate) fn cause( { return Err(invalid()); } + input_cause(conn, &input, dependency.id.clone()) +} + +/// Shared Notes field comparison; callers establish exact recorded consumption. +pub(crate) fn input_cause( + conn: &Connection, + input: &TimelineNotesInput, + dependency_id: SemanticDependencyId, +) -> Result, HistoryStoreError> { let live: bool = conn.query_row( "SELECT EXISTS(SELECT 1 FROM nodes WHERE id=?1)", [input.node_id.0.to_string()], @@ -197,7 +248,7 @@ pub(crate) fn cause( )?; let (current_revision_event_id, reason) = if live { let current = capture(conn, input.node_id)?; - if current == input { + if current == *input { return Ok(None); } (current.revision_event_id, ScriptImpactReason::Changed) @@ -205,8 +256,10 @@ pub(crate) fn cause( (None, ScriptImpactReason::Deleted) }; Ok(Some(ScriptImpactCause { - dependency_id: dependency.id.clone(), - input: dependency.target.clone(), + dependency_id, + input: SemanticDependencyEndpoint::TimelineNode { + node_id: input.node_id, + }, consumed_revision_event_id: input.revision_event_id.ok_or_else(invalid)?, current_revision_event_id, reason, @@ -232,4 +285,4 @@ pub(crate) fn impact( #[cfg(test)] #[path = "timeline_notes_lineage_tests.rs"] -mod tests; +pub(crate) mod tests; diff --git a/crates/server/src/timeline_notes_lineage_tests.rs b/crates/server/src/timeline_notes_lineage_tests.rs index 99a859bd..a63854f7 100644 --- a/crates/server/src/timeline_notes_lineage_tests.rs +++ b/crates/server/src/timeline_notes_lineage_tests.rs @@ -9,12 +9,13 @@ const OLD: &str = " Mara conceals the witness — 雨.\n\n "; const NEW: &str = " Mara reveals the witness — 雨.\n\n "; const PREVIEW: &str = " Synthetic preview: Mara identifies the witness — 雨.\n\n "; -fn notes(conn: &mut Connection, project: &mut Project, node: NodeId, text: &str) { +pub(crate) fn notes(conn: &mut Connection, project: &mut Project, node: NodeId, text: &str) { project.timeline.nodes = timeline_node_store::load_nodes(conn).unwrap(); timeline_command_history::record_set_timeline_node_notes_history( conn, project, &CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: node, notes: text.into(), }), @@ -49,7 +50,7 @@ fn impact( .unwrap() } -fn materials(conn: &Connection) -> String { +pub(crate) fn materials(conn: &Connection) -> String { let doc = crate::script_store::load_document_projection( conn, &ScriptDocumentId::new("script.document.main").unwrap(), @@ -66,7 +67,7 @@ fn materials(conn: &Connection) -> String { .unwrap() } -fn logical_rows(conn: &Connection) -> Vec<(String, Vec)> { +pub(crate) fn logical_rows(conn: &Connection) -> Vec<(String, Vec)> { let mut tables = conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").unwrap(); let names = tables .query_map([], |row| row.get::<_, String>(0)) @@ -460,3 +461,35 @@ fn historical_target_receipt_rejects_forged_notes_and_other_node_clock() { .id; assert!(from_target(&conn, &target).is_err()); } + +#[test] +fn explicit_guarded_notes_save_updates_existing_impact_without_replacing_any_saved_screenplay() { + let (mut conn, mut project, generation) = setup(); + let node = generation.payload.target_binding.as_ref().unwrap().node_id; + let material = materials(&conn); + project.timeline.nodes = timeline_node_store::load_nodes(&conn).unwrap(); + let expected = capture(&conn, node).unwrap(); + let command = CommandEnvelope::new(SetTimelineNodeNotesCommand { + node_id: node, + notes: NEW.into(), + expected: Some(expected), + }); + timeline_command_history::record_set_timeline_node_notes_history( + &mut conn, &project, &command, 300, + ) + .unwrap(); + project.timeline.nodes = timeline_node_store::load_nodes(&conn).unwrap(); + assert_eq!(materials(&conn), material); + let affected = impact(&conn, &generation); + assert!(!affected.causes.is_empty()); + assert!( + affected + .causes + .iter() + .any(|cause| cause.input == SemanticDependencyEndpoint::TimelineNode { node_id: node }) + ); + assert_eq!( + command_receipt(&conn, &command).unwrap(), + capture(&conn, node).unwrap() + ); +} diff --git a/crates/server/src/timeline_postcommit_custody_tests.rs b/crates/server/src/timeline_postcommit_custody_tests.rs index 9e85588e..7f0a489d 100644 --- a/crates/server/src/timeline_postcommit_custody_tests.rs +++ b/crates/server/src/timeline_postcommit_custody_tests.rs @@ -75,6 +75,7 @@ impl Fixture { .unwrap(); let mut conn = crate::sqlite::open_write_connection(path).unwrap(); let command = CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: node, notes: notes.into(), }); @@ -132,6 +133,7 @@ async fn postcommit_events_wait_for_document_enqueue_before_project_transition() proxy.send(DocCommand::Serialize { reply }).await.unwrap(); let mut events = fixture.state.events_tx.subscribe(); let command = CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: fixture.node, notes: "A queued document edit".into(), }); @@ -215,6 +217,7 @@ async fn postcommit_notes_finish_before_project_load_and_preserve_existing_histo let history_b = history(&fixture.path_b); let held = fixture.state.project_session_gate.lock().await; let command = CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: fixture.node, notes: "A committed edit".into(), }); @@ -284,6 +287,7 @@ async fn queued_command_rejects_same_path_reopen_without_events_or_history() { let mut events = fixture.state.events_tx.subscribe(); let held = fixture.state.project_session_gate.lock().await; let command = CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: fixture.node, notes: "obsolete".into(), }); @@ -326,6 +330,7 @@ async fn queued_command_rejects_save_as_without_writing_either_database() { )); assert_pending(save.as_mut()); let command = CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: fixture.node, notes: "obsolete".into(), }); @@ -341,6 +346,128 @@ async fn queued_command_rejects_save_as_without_writing_either_database() { fixture.finish().await; } +#[tokio::test] +async fn project_load_sqlite_with_json_suffix_keeps_selected_save_target() { + let fixture = Fixture::new().await; + let selected = fixture.directory.join("selected.json"); + let sibling = fixture.directory.join("project.db"); + std::fs::copy(&fixture.path_b, &selected).unwrap(); + std::fs::copy(&fixture.path_a, &sibling).unwrap(); + let sibling_before = std::fs::read(&sibling).unwrap(); + let selected_history = history(&selected); + let (autosave_tx, mut autosave_rx) = tokio::sync::mpsc::unbounded_channel(); + let _autosave = crate::write_concurrency_probe::autosave(selected.clone(), autosave_tx); + + let loaded = load_project( + &fixture.state, + LoadProjectRequest { + path: selected.display().to_string(), + }, + ) + .await + .unwrap(); + assert_eq!(loaded["name"], "B"); + assert_eq!( + fixture.state.project_database.active_path(), + Some(selected.clone()) + ); + crate::project_service::update_project( + &fixture.state, + crate::project_service::UpdateProjectRequest { + name: None, + premise: Some("Autosaved to the selected SQLite file".into()), + }, + ) + .unwrap(); + assert_eq!( + ydoc::read_content(&fixture.state.doc_tx, fixture.node) + .await + .unwrap() + .notes, + "B existing revision" + ); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + match autosave_rx + .recv() + .await + .expect("autosave probe remains open") + { + crate::write_concurrency_probe::AutosaveStage::BeforePersistence => {} + crate::write_concurrency_probe::AutosaveStage::Persisted(result) => { + result.unwrap(); + break; + } + } + } + }) + .await + .expect("selected SQLite file is autosaved"); + let (saved, blob) = crate::persistence::load_project(&selected).await.unwrap(); + assert_eq!(saved.name, "B"); + assert_eq!(saved.premise, "Autosaved to the selected SQLite file"); + assert!(blob.is_some()); + assert_eq!(history(&selected), selected_history); + + let saved = save_project(&fixture.state, SaveProjectRequest { path: None }) + .await + .unwrap(); + assert_eq!(saved["saved"], selected.display().to_string()); + assert_eq!(std::fs::read(&sibling).unwrap(), sibling_before); + fixture.finish().await; +} + +#[tokio::test] +async fn project_load_rejects_json_without_creating_or_changing_sibling() { + let fixture = Fixture::new().await; + let selected = fixture.directory.join("selected.json"); + let sibling = fixture.directory.join("project.db"); + let session_id = *fixture.state.project_session_id.lock(); + let before_history = history(&fixture.path_a); + let mut events = fixture.state.events_tx.subscribe(); + + for sibling_exists in [false, true] { + if sibling_exists { + std::fs::copy(&fixture.path_b, &sibling).unwrap(); + } + let sibling_before = std::fs::read(&sibling).ok(); + for json in [r#"{"name":"Legacy JSON"}"#, "{ malformed JSON"] { + std::fs::write(&selected, json).unwrap(); + let error = load_project( + &fixture.state, + LoadProjectRequest { + path: selected.display().to_string(), + }, + ) + .await + .unwrap_err(); + assert!(matches!(error, BackendError::BadRequest(_))); + assert!(error.message().contains("unsupported project schema")); + assert_eq!(std::fs::read_to_string(&selected).unwrap(), json); + assert_eq!(std::fs::read(&sibling).ok(), sibling_before); + assert_eq!(*fixture.state.project_session_id.lock(), session_id); + assert_eq!(fixture.state.project.lock().as_ref().unwrap().name, "A"); + assert_eq!( + fixture.state.project_database.active_path(), + Some(fixture.path_a.clone()) + ); + assert_eq!(history(&fixture.path_a), before_history); + assert_eq!( + ydoc::read_content(&fixture.state.doc_tx, fixture.node) + .await + .unwrap() + .notes, + "A existing revision" + ); + assert!(matches!( + events.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); + } + } + fixture.finish().await; +} + #[tokio::test] async fn failed_document_population_returns_error_without_publishing_project() { let fixture = Fixture::new().await; @@ -366,6 +493,7 @@ async fn failed_document_population_returns_error_without_publishing_project() { async fn failed_postcommit_document_send_reports_committed_failure() { let fixture = Fixture::new().await; let command = CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: fixture.node, notes: "committed before doc failure".into(), }); @@ -404,6 +532,7 @@ async fn transition_reopen_keeps_committed_notes_in_sqlite_and_document() { set_timeline_node_notes( &fixture.state, CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: fixture.node, notes: "A edit after earlier blob".into(), }), @@ -456,6 +585,7 @@ async fn transition_save_as_copies_committed_source_timeline_and_document() { set_timeline_node_notes( &fixture.state, CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: fixture.node, notes: "committed source notes".into(), }), @@ -681,6 +811,7 @@ async fn transition_cancelled_notes_caller_still_publishes_and_flushes_source_do let (reply, serialized) = tokio::sync::oneshot::channel(); proxy.send(DocCommand::Serialize { reply }).await.unwrap(); let command = CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: fixture.node, notes: "A edit survives cancelled response".into(), }); @@ -771,6 +902,7 @@ async fn normal_runtime_repeated_commands_bound_completed_supervisor_records() { set_timeline_node_notes( &fixture.state, CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: fixture.node, notes: format!("normal command {index}"), }), @@ -853,6 +985,7 @@ async fn assert_same_project_aliases_preserve_custody(fixture: Fixture, spelling let mut edit = Box::pin(set_timeline_node_notes( &fixture.state, CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: fixture.node, notes: "alias-admitted edit".into(), }), diff --git a/crates/server/src/timeline_script_placement_tests.rs b/crates/server/src/timeline_script_placement_tests.rs index ff64473d..c1c84e74 100644 --- a/crates/server/src/timeline_script_placement_tests.rs +++ b/crates/server/src/timeline_script_placement_tests.rs @@ -82,6 +82,9 @@ pub(crate) fn fixture() -> ( script_document_command::apply_generated_script_block( &mut conn, &CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, arc_inputs: None, target_binding: None, script_context_scope: None, @@ -442,6 +445,9 @@ fn historical_sparse_placement_validates_captured_input_after_later_move_and_rej evidence.start_ms += 1; } let generation = CommandEnvelope::new(GenerateScriptBlockCommand { + arc_description_applicability: None, + timeline_title_inputs: None, + ancestor_notes_inputs: None, arc_inputs: None, target_binding: None, script_context_scope: None, diff --git a/crates/server/src/timeline_title_command_tests.rs b/crates/server/src/timeline_title_command_tests.rs new file mode 100644 index 00000000..4ccae0f6 --- /dev/null +++ b/crates/server/src/timeline_title_command_tests.rs @@ -0,0 +1,333 @@ +use super::*; +use crate::{timeline_node_store, timeline_relationship_store}; +use eidetic_core::timeline::relationship::{Relationship, RelationshipType}; +use eidetic_core::{Project, Template}; + +fn fixture() -> (Connection, Project) { + let mut project = Template::MultiCam.build_project("Shared timeline"); + project.timeline.relationships.push(Relationship::new( + project.timeline.nodes[0].id, + project.timeline.nodes[1].id, + RelationshipType::Thematic, + )); + let mut conn = Connection::open_in_memory().unwrap(); + history_store::create_schema(&conn).unwrap(); + conn.execute_batch("CREATE TABLE project (id INTEGER PRIMARY KEY, total_duration_ms INTEGER);") + .unwrap(); + conn.execute( + "INSERT INTO project VALUES (1, ?1)", + [project.timeline.total_duration_ms], + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + timeline_node_store::upsert_nodes_in_transaction(&tx, &project.timeline.nodes).unwrap(); + timeline_node_store::replace_node_arcs_in_transaction(&tx, &project.timeline.node_arcs) + .unwrap(); + timeline_relationship_store::upsert_relationships_in_transaction( + &tx, + &project.timeline.relationships, + ) + .unwrap(); + tx.commit().unwrap(); + (conn, project) +} + +fn command( + conn: &Connection, + project: &Project, + name: &str, +) -> CommandEnvelope { + let node = &project.timeline.nodes[0]; + CommandEnvelope::new(SetTimelineNodeNameCommand { + node_id: node.id, + name: name.into(), + expected: TimelineNodeNameRead { + name: node.name.clone(), + revision_event_id: timeline_node_store::latest_name_event(conn, node.id, None).unwrap(), + }, + }) +} +fn reload(conn: &Connection, project: &mut Project) { + let loaded = timeline_node_store::load_nodes(conn).unwrap(); + for node in &mut project.timeline.nodes { + *node = loaded + .iter() + .find(|current| current.id == node.id) + .unwrap() + .clone(); + } +} +fn count(conn: &Connection) -> i64 { + conn.query_row("SELECT count(*) FROM commands", [], |r| r.get(0)) + .unwrap() +} +#[test] +fn review_identical_title_refuses_without_advancing_name_clock_or_any_rows() { + let (mut conn, mut project) = fixture(); + let original = command(&conn, &project, &project.timeline.nodes[0].name); + let before = crate::timeline_notes_lineage::tests::logical_rows(&conn); + let error = + record_set_timeline_node_name_history(&mut conn, &project, &original, 1).unwrap_err(); + assert!(error.to_string().contains("title is unchanged")); + assert_eq!( + crate::timeline_notes_lineage::tests::logical_rows(&conn), + before + ); + assert_eq!( + timeline_node_store::latest_name_event(&conn, original.payload.node_id, None).unwrap(), + None + ); + + let changed = command(&conn, &project, "Other writer"); + record_set_timeline_node_name_history(&mut conn, &project, &changed, 2).unwrap(); + reload(&conn, &mut project); + let restore = command(&conn, &project, &original.payload.name); + record_set_timeline_node_name_history(&mut conn, &project, &restore, 3).unwrap(); + reload(&conn, &mut project); + let before = crate::timeline_notes_lineage::tests::logical_rows(&conn); + let error = + record_set_timeline_node_name_history(&mut conn, &project, &original, 4).unwrap_err(); + assert!( + error.to_string().contains("title changed; reload"), + "expected-read validation precedes unchanged refusal: {error}" + ); + assert_eq!( + crate::timeline_notes_lineage::tests::logical_rows(&conn), + before + ); +} + +#[test] +fn exact_title_persists_with_old_new_history_and_replays_before_stale_read() { + let (mut conn, mut project) = fixture(); + let before = project.timeline.clone(); + let command = command(&conn, &project, " Station departure — 雨. "); + assert_eq!( + record_set_timeline_node_name_history(&mut conn, &project, &command, 1).unwrap(), + RecordChangeOutcome::Recorded + ); + reload(&conn, &mut project); + let mut expected = before.clone(); + expected.nodes[0].name = command.payload.name.clone(); + assert_eq!( + serde_json::to_value(&project.timeline).unwrap(), + serde_json::to_value(&expected).unwrap() + ); + let revisions = history_store::load_revisions_for_object( + &conn, + ObjectKind::TimelineNode, + &command.payload.node_id.0.to_string(), + ) + .unwrap(); + assert_eq!(revisions.len(), 1); + assert_eq!( + revisions[0].fields, + vec![FieldDelta::new( + "name", + Some(FieldValue::Text(before.nodes[0].name.clone())), + Some(FieldValue::Text(command.payload.name.clone())) + )] + ); + assert_eq!( + timeline_node_store::latest_name_event(&conn, command.payload.node_id, None).unwrap(), + Some(revisions[0].change_event_id) + ); + let path = std::env::temp_dir().join(format!("eidetic-title-{}.db", uuid::Uuid::new_v4())); + conn.execute("VACUUM INTO ?1", [path.to_str().unwrap()]) + .unwrap(); + let mut reopened = Connection::open(&path).unwrap(); + assert_eq!( + record_set_timeline_node_name_history(&mut reopened, &project, &command, 2).unwrap(), + RecordChangeOutcome::AlreadyRecorded + ); + assert_eq!(count(&reopened), 1); + let mut restored = project.clone(); + reload(&reopened, &mut restored); + assert_eq!( + serde_json::to_value(&restored.timeline).unwrap(), + serde_json::to_value(&project.timeline).unwrap() + ); + let mut changed = command.clone(); + changed.payload.name = "Another".into(); + assert!(record_set_timeline_node_name_history(&mut reopened, &project, &changed, 3).is_err()); + drop(reopened); + std::fs::remove_file(path).unwrap(); +} +#[test] +fn concurrent_and_aba_title_reads_refuse_without_history_writes() { + let (mut conn, mut project) = fixture(); + let initial = project.clone(); + let stale = command(&conn, &project, "My pending title"); + let first = command(&conn, &project, "Other writer"); + record_set_timeline_node_name_history(&mut conn, &project, &first, 1).unwrap(); + let n = count(&conn); + assert!(record_set_timeline_node_name_history(&mut conn, &initial, &stale, 2).is_err()); + assert_eq!(count(&conn), n); + reload(&conn, &mut project); + let restore = command(&conn, &project, &initial.timeline.nodes[0].name); + record_set_timeline_node_name_history(&mut conn, &project, &restore, 3).unwrap(); + reload(&conn, &mut project); + let n = count(&conn); + assert!(record_set_timeline_node_name_history(&mut conn, &project, &stale, 4).is_err()); + assert_eq!(count(&conn), n); +} +#[test] +fn non_title_edit_keeps_name_clock_and_invalid_title_never_records() { + let (mut conn, mut project) = fixture(); + let intent = command(&conn, &project, "Author title"); + let notes = CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, + node_id: intent.payload.node_id, + notes: "Authored notes".into(), + }); + record_set_timeline_node_notes_history(&mut conn, &project, ¬es, 1).unwrap(); + reload(&conn, &mut project); + record_set_timeline_node_name_history(&mut conn, &project, &intent, 2).unwrap(); + reload(&conn, &mut project); + assert_eq!(project.timeline.nodes[0].content.notes, "Authored notes"); + let n = count(&conn); + for name in [" ", "bad\nline", "\0"] { + let invalid = command(&conn, &project, name); + assert!(record_set_timeline_node_name_history(&mut conn, &project, &invalid, 3).is_err()); + assert_eq!(count(&conn), n); + } +} + +#[tokio::test] +async fn public_title_command_refreshes_native_projection_prompt_and_survives_save_reload() { + use crate::{ + command_service, persistence, project_service, projection_service, state::AppState, + }; + use eidetic_core::timeline::node::StoryLevel; + let state = AppState::new().await; + let project = Template::MultiCam.build_project("Title service custody"); + let node = project.timeline.nodes_at_level(StoryLevel::Scene)[0].id; + let path = persistence::default_project_dir() + .join(format!("title-service-{}.db", uuid::Uuid::new_v4())); + persistence::save_project(&project, &path, None) + .await + .unwrap(); + project_service::replace_active_project(&state, project, path.clone()); + let original = projection_service::selected_node_editor_projection( + &state, + projection_service::SelectedNodeEditorProjectionRequest { + node_id: Some(node), + }, + ) + .await + .unwrap(); + let expected = original.payload.node.unwrap().name_read.unwrap(); + let command = CommandEnvelope::new(SetTimelineNodeNameCommand { + node_id: node, + name: " Station departure — 雨. ".into(), + expected, + }); + let response = command_service::set_timeline_node_name(&state, command.clone()) + .await + .unwrap(); + assert_eq!( + serde_json::to_value(&response).unwrap()["outcome"], + "recorded" + ); + let rendered = serde_json::to_value(&response).unwrap(); + assert!( + rendered["projection"]["payload"]["clips"] + .as_array() + .unwrap() + .iter() + .any(|clip| clip["node_id"] == node.0.to_string() + && clip["name"] == " Station departure — 雨. ") + ); + let after = projection_service::selected_node_editor_projection( + &state, + projection_service::SelectedNodeEditorProjectionRequest { + node_id: Some(node), + }, + ) + .await + .unwrap(); + assert_eq!( + after.payload.node.as_ref().unwrap().name, + command.payload.name + ); + assert_eq!( + after + .payload + .node + .as_ref() + .unwrap() + .name_read + .as_ref() + .unwrap() + .name, + command.payload.name + ); + let (current, _) = crate::ai_service::active_sqlite_project(&state) + .await + .unwrap(); + let mut request = eidetic_core::ai::prompt::build_generate_request(¤t, node).unwrap(); + crate::ai_service::attach_ai_generation_context(&mut request, path.clone(), node) + .await + .unwrap(); + assert!( + crate::prompt_format::build_chat_prompt(&request) + .user + .contains(&command.payload.name) + ); + assert!( + request + .timeline_title_inputs + .as_ref() + .unwrap() + .iter() + .any(|input| input.node_id == node + && input.name == command.payload.name + && input.revision_event_id.is_some()) + ); + let before = crate::timeline_postcommit_custody_tests::history(&path); + command_service::set_timeline_node_name(&state, command) + .await + .unwrap(); + assert_eq!( + crate::timeline_postcommit_custody_tests::history(&path), + before + ); + let mut events = state.events_tx.subscribe(); + let unchanged = CommandEnvelope::new(SetTimelineNodeNameCommand { + node_id: node, + name: " Station departure — 雨. ".into(), + expected: after.payload.node.unwrap().name_read.unwrap(), + }); + let error = command_service::set_timeline_node_name(&state, unchanged) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("Title edit refused: title is unchanged") + ); + assert_eq!( + crate::timeline_postcommit_custody_tests::history(&path), + before + ); + while let Ok(event) = events.try_recv() { + assert!(!matches!( + event, + crate::state::ServerEvent::TimelineChanged + | crate::state::ServerEvent::HierarchyChanged + | crate::state::ServerEvent::ScriptChanged + )); + } + project_service::save_project(&state, project_service::SaveProjectRequest { path: None }) + .await + .unwrap(); + let (reopened, _) = persistence::load_project(&path).await.unwrap(); + assert_eq!( + reopened.timeline.node(node).unwrap().name, + " Station departure — 雨. " + ); + state.shutdown_tasks(); + for suffix in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{suffix}", path.display())); + } +} diff --git a/crates/server/src/timeline_title_lineage.rs b/crates/server/src/timeline_title_lineage.rs new file mode 100644 index 00000000..deb758f8 --- /dev/null +++ b/crates/server/src/timeline_title_lineage.rs @@ -0,0 +1,322 @@ +//! Exact consumed titles reuse generation commands, proposal bindings and graph edges. +use crate::history_store::HistoryStoreError; +use eidetic_core::ai::backend::GenerateRequest; +use eidetic_core::contracts::*; +use eidetic_core::timeline::node::NodeId; +use rusqlite::{Connection, OptionalExtension, params}; + +fn invalid() -> HistoryStoreError { + HistoryStoreError::InvalidValue("consumed timeline title evidence is stale or invalid".into()) +} + +/// These are precisely the node names formatted by build_chat_prompt. Never +/// resolve a recap name by text: distinct nodes can have identical titles. +pub(crate) fn supplied_titles( + request: &GenerateRequest, +) -> Result, HistoryStoreError> { + let mut supplied = vec![(request.target_node.id, request.target_node.name.clone())]; + supplied.extend( + request + .ancestor_chain + .iter() + .chain(&request.siblings) + .map(|node| (node.id, node.name.clone())), + ); + for recap in &request.surrounding_context.preceding_recaps { + supplied.push((recap.node_id.ok_or_else(invalid)?, recap.node_name.clone())); + } + let mut unique: Vec<(NodeId, String)> = Vec::new(); + for (id, name) in supplied { + if let Some((_, prior)) = unique.iter().find(|(node, _)| *node == id) { + if *prior != name { + return Err(invalid()); + } + } else { + unique.push((id, name)); + } + } + Ok(unique) +} + +pub(crate) fn capture( + conn: &Connection, + node_id: NodeId, +) -> Result { + let name: String = conn + .query_row( + "SELECT name FROM nodes WHERE id=?1", + [node_id.0.to_string()], + |row| row.get(0), + ) + .optional()? + .ok_or_else(invalid)?; + let input = TimelineTitleInput { + node_id, + name, + revision_event_id: crate::timeline_node_store::latest_name_event(conn, node_id, None)?, + }; + validate_history(conn, &input)?; + Ok(input) +} + +pub(crate) fn capture_generation( + conn: &Connection, + supplied: &[(NodeId, String)], +) -> Result, HistoryStoreError> { + let mut inputs = Vec::new(); + for (id, name) in supplied { + let input = capture(conn, *id)?; + if input.name != *name { + return Err(invalid()); + } + inputs.push(input); + } + validate_inputs(conn, &inputs)?; + Ok(inputs) +} + +fn validate_history( + conn: &Connection, + input: &TimelineTitleInput, +) -> Result<(), HistoryStoreError> { + let Some(event) = input.revision_event_id else { + return Ok(()); + }; + let owns: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM object_revisions r JOIN object_revision_fields f ON f.revision_id=r.id + WHERE r.object_kind='timeline_node' AND r.object_id=?1 AND r.change_event_id=?2 + AND f.field_key='name' AND f.new_type='text' AND f.new_text=?3)", + params![input.node_id.0.to_string(), event.0.to_string(), input.name], |row| row.get(0))?; + let historical = crate::revision_projection::load_object_field_projection_at_event( + conn, + ObjectKind::TimelineNode, + &input.node_id.0.to_string(), + event, + )? + .ok_or_else(invalid)?; + if !owns + || historical.deleted + || historical.fields.get("name") != Some(&FieldValue::Text(input.name.clone())) + { + return Err(invalid()); + } + Ok(()) +} + +pub(crate) fn validate_inputs( + conn: &Connection, + inputs: &[TimelineTitleInput], +) -> Result<(), HistoryStoreError> { + let mut seen = std::collections::BTreeSet::new(); + for input in inputs { + if !seen.insert(input.node_id.0) { + return Err(invalid()); + } + validate_history(conn, input)?; + } + Ok(()) +} + +/// Writer admission retains exact values and owned clocks, including baseline +/// title edit/restore ABA. An unrelated Notes/placement edit is not a title edit. +pub(crate) fn validate_admission( + conn: &Connection, + command: &GenerateScriptBlockCommand, +) -> Result<(), HistoryStoreError> { + let Some(inputs) = &command.timeline_title_inputs else { + return Ok(()); + }; + validate_inputs(conn, inputs)?; + let target = command + .block + .source_node_id + .as_deref() + .ok_or_else(invalid)?; + if !inputs + .iter() + .any(|input| input.node_id.0.to_string() == target) + { + return Err(invalid()); + } + for input in inputs { + if capture(conn, input.node_id)? != *input { + return Err(invalid()); + } + } + Ok(()) +} + +fn dependency_id(event: ChangeEventId, node: NodeId) -> SemanticDependencyId { + SemanticDependencyId::new(format!("generation.{}.timeline_title.{}", event.0, node.0)) + .expect("title dependency identity") +} + +pub(crate) fn dependencies( + conn: &Connection, + inputs: Option<&[TimelineTitleInput]>, + segment: &ScriptSegmentId, + event: ChangeEventId, + created_at_ms: u64, +) -> Result, HistoryStoreError> { + let Some(inputs) = inputs else { + return Ok(Vec::new()); + }; + validate_inputs(conn, inputs)?; + Ok(inputs.iter().map(|input| SemanticDependency { + id: dependency_id(event, input.node_id), + source: SemanticDependencyEndpoint::ScriptSegment {segment_id: segment.clone()}, + target: SemanticDependencyEndpoint::TimelineNode {node_id: input.node_id}, + kind: SemanticDependencyKind::DerivesFrom, + rationale: Some("Exact timeline title supplied to generation or targeted review; baseline clock is a read stamp".into()), + confidence: None, created_at_ms, + revision_binding: Some(SemanticDependencyRevisionBinding { + source_revision_event_id: event, + // For baseline titles this is the generation read stamp, not an + // invented name authoring revision. The exact owned clock is None + // in the durable consumed-title receipt, used for all comparisons. + target_revision_event_id: input.revision_event_id.unwrap_or(event), + }), + }).collect()) +} + +/// Optional legacy receipts remain unknown, never reconstructed from today. +pub(crate) fn recorded( + conn: &Connection, + event: ChangeEventId, +) -> Result>, HistoryStoreError> { + let (kind, json): (String, String) = conn.query_row( + "SELECT c.payload_type,c.payload_json FROM commands c JOIN change_events e ON e.command_id=c.id WHERE e.id=?1", + [event.0.to_string()], |row| Ok((row.get(0)?, row.get(1)?)))?; + let inputs = match kind.as_str() { + "script.generate_block" => { + serde_json::from_str::(&json)?.timeline_title_inputs + } + "semantic.propagation_accept" => { + let command: AcceptPropagationProposalCommand = serde_json::from_str(&json)?; + let json: String = conn.query_row( + "SELECT binding_json FROM script_impact_proposal_bindings WHERE proposal_id=?1", + [command.proposal_id.as_str()], + |row| row.get(0), + )?; + serde_json::from_str::(&json)?.timeline_title_current + } + _ => None, + }; + if let Some(inputs) = &inputs { + validate_inputs(conn, inputs)?; + } + Ok(inputs) +} + +pub(crate) fn impact( + conn: &Connection, + event: ChangeEventId, +) -> Result, HistoryStoreError> { + let mut causes = Vec::new(); + for input in recorded(conn, event)?.iter().flatten() { + let live: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM nodes WHERE id=?1)", + [input.node_id.0.to_string()], + |row| row.get(0), + )?; + let (current_revision_event_id, reason) = if live { + let current = capture(conn, input.node_id)?; + if current == *input { + continue; + } + (current.revision_event_id, ScriptImpactReason::Changed) + } else { + (None, ScriptImpactReason::Deleted) + }; + causes.push(ScriptImpactCause { + dependency_id: dependency_id(event, input.node_id), + input: SemanticDependencyEndpoint::TimelineNode { + node_id: input.node_id, + }, + consumed_revision_event_id: input.revision_event_id.unwrap_or(event), + current_revision_event_id, + reason, + input_excerpt: Some(input.name.clone()), + }); + } + Ok(causes) +} + +pub(crate) struct PreviewInputs { + pub previous: Option>, + pub current: Option>, + pub absent: Option>, +} + +/// Refresh only actually consumed identities. Owned deletion clocks bind +/// absence without inventing current title prose or adding unrelated nodes. +pub(crate) fn preview_inputs( + conn: &Connection, + event: ChangeEventId, +) -> Result { + let previous = recorded(conn, event)?; + let Some(inputs) = &previous else { + return Ok(PreviewInputs { + previous: None, + current: None, + absent: None, + }); + }; + let mut current = Vec::new(); + let mut absent = Vec::new(); + for input in inputs { + let live: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM nodes WHERE id=?1)", + [input.node_id.0.to_string()], + |row| row.get(0), + )?; + if live { + current.push(capture(conn, input.node_id)?); + } else { + let deletion = + crate::timeline_node_store::latest_name_event(conn, input.node_id, None)? + .ok_or_else(invalid)?; + let historical = crate::revision_projection::load_object_field_projection_at_event( + conn, + ObjectKind::TimelineNode, + &input.node_id.0.to_string(), + deletion, + )? + .ok_or_else(invalid)?; + if !historical.deleted { + return Err(invalid()); + } + absent.push((input.node_id, deletion)); + } + } + Ok(PreviewInputs { + previous, + current: Some(current), + absent: Some(absent), + }) +} + +pub(crate) fn append_prompt(prompt: &mut String, binding: &ScriptImpactProposalBinding) { + for input in binding.timeline_title_previous.iter().flatten() { + prompt.push_str(&format!( + "\nORIGINAL CONSUMED TIMELINE TITLE ({}):\n{}\n", + input.node_id.0, input.name + )); + } + for input in binding.timeline_title_current.iter().flatten() { + prompt.push_str(&format!( + "\nCURRENT CONSUMED TIMELINE TITLE ({}):\n{}\n", + input.node_id.0, input.name + )); + } + for (node, _) in binding.timeline_title_absence_revisions.iter().flatten() { + prompt.push_str(&format!( + "\nPREVIOUSLY CONSUMED TIMELINE TITLE SOURCE REMOVED: {}\n", + node.0 + )); + } +} + +#[cfg(test)] +#[path = "timeline_title_lineage_tests.rs"] +mod tests; diff --git a/crates/server/src/timeline_title_lineage_tests.rs b/crates/server/src/timeline_title_lineage_tests.rs new file mode 100644 index 00000000..b4d0f695 --- /dev/null +++ b/crates/server/src/timeline_title_lineage_tests.rs @@ -0,0 +1,692 @@ +use super::*; +use crate::script_impact_review::tests::{accept, edit, request}; +use crate::timeline_notes_lineage::tests::{logical_rows, materials}; +use crate::{ + script_document_command, script_impact_review, timeline_command_history, timeline_node_store, +}; +use eidetic_core::Project; +const NEW: &str = " Station departure — 雨. "; +const HUMAN: &str = " Authored B: Eli keeps his whistle — 雨.\n\n"; +const PREVIEW: &str = " Synthetic reviewed B: Eli departs — 雨.\n\n"; + +fn rename(conn: &mut Connection, project: &mut Project, node: NodeId, name: &str) { + project.timeline.nodes = timeline_node_store::load_nodes(conn).unwrap(); + let original = capture(conn, node).unwrap(); + timeline_command_history::record_set_timeline_node_name_history( + conn, + project, + &CommandEnvelope::new(SetTimelineNodeNameCommand { + node_id: node, + name: name.into(), + expected: TimelineNodeNameRead { + name: original.name, + revision_event_id: original.revision_event_id, + }, + }), + 200, + ) + .unwrap(); + project.timeline.nodes = timeline_node_store::load_nodes(conn).unwrap(); +} +fn setup( + persist: bool, +) -> ( + Connection, + Project, + CommandEnvelope, +) { + let (mut conn, project, mut generation) = crate::script_generation_target::tests::fixture(); + let target = generation.payload.target_binding.as_ref().unwrap().node_id; + let prompt = eidetic_core::ai::prompt::build_generate_request(&project, target).unwrap(); + generation.payload.timeline_title_inputs = + Some(capture_generation(&conn, &supplied_titles(&prompt).unwrap()).unwrap()); + if persist { + script_document_command::apply_generated_script_block(&mut conn, &generation, 210).unwrap(); + } + (conn, project, generation) +} +fn impact( + conn: &Connection, + generation: &CommandEnvelope, +) -> ScriptImpactProjection { + crate::script_impact_projection::load_impact(conn, &generation.payload.block.segment_id) + .unwrap() + .unwrap() +} +fn preview( + conn: &mut Connection, + generation: &CommandEnvelope, +) -> CommandEnvelope { + let command = request(conn, &generation.payload.block); + let binding = script_impact_review::capture(conn, &command.payload).unwrap(); + script_impact_review::record_proposal(conn, &command, binding, PREVIEW.into(), 220).unwrap(); + command +} + +#[test] +fn review_identical_consumed_title_cannot_create_a_screenplay_review_cause() { + let (mut conn, mut project, generation) = setup(true); + project.timeline.nodes = timeline_node_store::load_nodes(&conn).unwrap(); + let target = generation.payload.target_binding.as_ref().unwrap().node_id; + let read = capture(&conn, target).unwrap(); + let before = logical_rows(&conn); + let before_impact = impact(&conn, &generation); + assert!(!before_impact.needs_review); + let error = timeline_command_history::record_set_timeline_node_name_history( + &mut conn, + &project, + &CommandEnvelope::new(SetTimelineNodeNameCommand { + node_id: target, + name: read.name.clone(), + expected: TimelineNodeNameRead { + name: read.name, + revision_event_id: read.revision_event_id, + }, + }), + 230, + ) + .unwrap_err(); + assert!(error.to_string().contains("title is unchanged")); + assert_eq!(logical_rows(&conn), before); + assert_eq!( + serde_json::to_value(impact(&conn, &generation)).unwrap(), + serde_json::to_value(before_impact).unwrap() + ); +} + +#[tokio::test] +async fn review_final_prompt_excludes_recap_title_receipts_and_recaps_cannot_refuse_or_flag_generation() + { + use eidetic_core::timeline::{ + node::{StoryLevel, StoryNode}, + timing::TimeRange, + }; + for late_rename in [false, true] { + let (_, mut project, mut generation) = setup(false); + let target = generation.payload.target_binding.as_ref().unwrap().node_id; + let preceding = project + .timeline + .siblings_of(target) + .iter() + .find(|node| { + node.id != target + && node.time_range.end_ms + < project.timeline.node(target).unwrap().time_range.start_ms + }) + .unwrap() + .id; + let mut recap = StoryNode::new_child( + " Recap-only title — 雨. ", + StoryLevel::Beat, + TimeRange::new(1100, 1200).unwrap(), + preceding, + ); + recap.content.scene_recap = + Some("Synthetic legacy recap that canonical screenplay replaces.".into()); + let recap_id = recap.id; + let recap_name = recap.name.clone(); + project.timeline.add_node(recap).unwrap(); + let path = std::env::temp_dir().join(format!( + "eidetic-title-final-prompt-{}.db", + uuid::Uuid::new_v4() + )); + crate::persistence::save_project(&project, &path, None) + .await + .unwrap(); + let mut conn = crate::sqlite::open_write_connection(&path).unwrap(); + let mut source = generation.payload.block.clone(); + source.segment_id = ScriptSegmentId::new("script.segment.canonical-a").unwrap(); + source.block_id = ScriptBlockId::new("script.block.canonical-a").unwrap(); + source.source_node_id = Some(preceding.0.to_string()); + source.segment_start_ms = project + .timeline + .node(preceding) + .unwrap() + .time_range + .start_ms; + source.segment_end_ms = project.timeline.node(preceding).unwrap().time_range.end_ms; + source.text = " Exact authored source A — 雨.\n\n ".into(); + source.span_provenance = ScriptSpanProvenance::UserEdited; + script_document_command::apply_set_script_block( + &mut conn, + &CommandEnvelope::new(source.clone()), + 205, + ) + .unwrap(); + drop(conn); + let mut request = + eidetic_core::ai::prompt::build_generate_request(&project, target).unwrap(); + assert!( + request + .surrounding_context + .preceding_recaps + .iter() + .any(|r| r.node_id == Some(recap_id)) + ); + assert!( + crate::prompt_format::build_chat_prompt(&request) + .user + .contains(&recap_name) + ); + crate::ai_service::attach_ai_generation_context(&mut request, path.clone(), target) + .await + .unwrap(); + let prompt = crate::prompt_format::build_chat_prompt(&request); + let inputs = request.timeline_title_inputs.as_ref().unwrap(); + assert!(prompt.user.contains(&source.text)); + assert!( + request + .script_context + .as_ref() + .unwrap() + .iter() + .any(|input| input.block_id == source.block_id && input.text == source.text) + ); + println!( + "TITLE_PROMPT_RECEIPT {}", + serde_json::json!({"late_recap_rename":late_rename,"final_user_prompt":prompt.user,"timeline_title_inputs":inputs,"script_inputs":request.script_context,"omitted_recap_node":recap_id,"omitted_recap_name":recap_name,"synthetic_saved_output":true,"real_model_execution":false}) + ); + assert!(request.surrounding_context.preceding_recaps.is_empty()); + assert!(!prompt.user.contains(&recap_name)); + assert!( + !inputs.iter().any(|input| input.node_id == recap_id), + "recap title absent from actual final prompt must not become consumed evidence" + ); + assert_eq!( + inputs + .iter() + .map(|input| (input.node_id, input.name.clone())) + .collect::>(), + supplied_titles(&request).unwrap() + ); + assert!(inputs.iter().all(|input| prompt.user.contains(&input.name))); + generation.payload.timeline_title_inputs = request.timeline_title_inputs; + generation.payload.script_inputs = request.script_context; + generation.payload.script_context_scope = request.script_context_scope; + generation.payload.target_binding = request.generation_target; + generation.payload.ancestor_notes_inputs = request.ancestor_notes_inputs; + generation.payload.arc_inputs = request.arc_inputs; + generation.payload.arc_description_applicability = request.arc_description_applicability; + generation.payload.bible_inputs = request.bible_inputs; + generation.payload.bible_context_scope = request.bible_context_scope; + generation.payload.bible_relationship_inputs = request.bible_relationship_inputs; + generation.payload.bible_node_name_inputs = request.bible_node_name_inputs; + let mut conn = crate::sqlite::open_write_connection(&path).unwrap(); + if late_rename { + rename(&mut conn, &mut project, recap_id, NEW); + } + script_document_command::apply_generated_script_block(&mut conn, &generation, 210).unwrap(); + assert!(!impact(&conn, &generation).needs_review); + let before = materials(&conn); + if !late_rename { + rename(&mut conn, &mut project, recap_id, NEW); + } + assert!( + !impact(&conn, &generation).needs_review, + "an omitted recap rename cannot flag saved screenplay" + ); + assert_eq!(materials(&conn), before); + let recorded = crate::timeline_title_lineage::recorded( + &conn, + impact(&conn, &generation).generation_event_id, + ) + .unwrap() + .unwrap(); + assert!(!recorded.iter().any(|input| input.node_id == recap_id)); + drop(conn); + for suffix in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{suffix}", path.display())); + } + } +} + +#[test] +fn baseline_selected_ancestor_and_sibling_title_changes_have_exact_distinct_causes() { + for role in ["selected", "ancestor", "sibling"] { + let (mut conn, mut project, generation) = setup(true); + let target = generation.payload.target_binding.as_ref().unwrap().node_id; + let node = match role { + "selected" => target, + "ancestor" => project.timeline.ancestors_of(target)[0].id, + _ => { + project + .timeline + .siblings_of(target) + .iter() + .find(|node| node.id != target) + .unwrap() + .id + } + }; + let old = capture(&conn, node).unwrap(); + assert_eq!( + old.revision_event_id, None, + "baseline name must not invent authored history" + ); + let event = impact(&conn, &generation).generation_event_id; + let before = materials(&conn); + rename(&mut conn, &mut project, node, NEW); + let changed = impact(&conn, &generation); + assert_eq!(changed.causes.len(), 1, "{role}"); + assert_eq!(changed.causes[0].dependency_id, dependency_id(event, node)); + assert_eq!( + changed.causes[0].input_excerpt.as_deref(), + Some(old.name.as_str()) + ); + assert_eq!( + changed.causes[0].consumed_revision_event_id, event, + "baseline is a read stamp" + ); + assert_eq!( + materials(&conn), + before, + "rename must never replace authored screenplay" + ); + } +} + +#[test] +fn canonical_prompt_capture_refuses_changed_input_and_never_resolves_names_by_text() { + let (mut conn, mut project, mut generation) = setup(false); + let target = generation.payload.target_binding.as_ref().unwrap().node_id; + let sibling = project + .timeline + .siblings_of(target) + .iter() + .find(|node| node.id != target) + .unwrap() + .id; + let target_title = project.timeline.node(target).unwrap().name.clone(); + rename(&mut conn, &mut project, sibling, &target_title); + let prompt = eidetic_core::ai::prompt::build_generate_request(&project, target).unwrap(); + let supplied = supplied_titles(&prompt).unwrap(); + let receipts = capture_generation(&conn, &supplied).unwrap(); + assert_eq!( + receipts + .iter() + .filter(|input| input.name == target_title) + .count(), + 2 + ); + generation.payload.timeline_title_inputs = Some(receipts); + script_document_command::apply_generated_script_block(&mut conn, &generation, 210).unwrap(); + let event = impact(&conn, &generation).generation_event_id; + assert_ne!(dependency_id(event, target), dependency_id(event, sibling)); + rename(&mut conn, &mut project, sibling, NEW); + assert!(capture_generation(&conn, &supplied).is_err()); + assert_eq!(impact(&conn, &generation).causes.len(), 1); +} + +#[test] +fn preceding_recap_title_has_its_actual_source_identity_and_unconsumed_title_is_out_of_scope() { + use eidetic_core::timeline::{ + node::{StoryLevel, StoryNode}, + timing::TimeRange, + }; + let (mut conn, mut project, mut generation) = setup(false); + let target = generation.payload.target_binding.as_ref().unwrap().node_id; + let preceding = project + .timeline + .siblings_of(target) + .iter() + .find(|node| { + node.id != target + && node.time_range.end_ms + < project.timeline.node(target).unwrap().time_range.start_ms + }) + .unwrap() + .id; + let mut recap = StoryNode::new_child( + "Recap-only 雨", + StoryLevel::Beat, + TimeRange::new(1100, 1200).unwrap(), + preceding, + ); + recap.content.scene_recap = Some("Synthetic canonical recap".into()); + let recap_id = recap.id; + let mut unrelated = recap.clone(); + unrelated.id = NodeId::new(); + unrelated.name = "Unconsumed".into(); + unrelated.content.scene_recap = None; + let unrelated_id = unrelated.id; + project.timeline.add_node(recap).unwrap(); + project.timeline.add_node(unrelated).unwrap(); + let tx = conn.transaction().unwrap(); + timeline_node_store::upsert_nodes_in_transaction(&tx, &project.timeline.nodes).unwrap(); + tx.commit().unwrap(); + let prompt = eidetic_core::ai::prompt::build_generate_request(&project, target).unwrap(); + assert!( + prompt + .surrounding_context + .preceding_recaps + .iter() + .any(|entry| entry.node_id == Some(recap_id)) + ); + let supplied = supplied_titles(&prompt).unwrap(); + assert!(supplied.iter().any(|(id, _)| *id == recap_id)); + assert!(!supplied.iter().any(|(id, _)| *id == unrelated_id)); + generation.payload.timeline_title_inputs = Some(capture_generation(&conn, &supplied).unwrap()); + script_document_command::apply_generated_script_block(&mut conn, &generation, 210).unwrap(); + rename(&mut conn, &mut project, unrelated_id, "Unrelated edit"); + assert!(!impact(&conn, &generation).needs_review); + rename(&mut conn, &mut project, recap_id, NEW); + let changed = impact(&conn, &generation); + assert_eq!(changed.causes.len(), 1); + assert_eq!( + changed.causes[0].input, + SemanticDependencyEndpoint::TimelineNode { node_id: recap_id } + ); + let mut legacy = prompt.clone(); + legacy.surrounding_context.preceding_recaps[0].node_id = None; + assert!( + supplied_titles(&legacy).is_err(), + "cannot guess recap source by its title" + ); +} + +#[test] +fn delayed_generation_refuses_changed_and_restored_consumed_titles_without_writes() { + for restored in [false, true] { + let (mut conn, mut project, generation) = setup(false); + let target = generation.payload.target_binding.as_ref().unwrap().node_id; + let sibling = project + .timeline + .siblings_of(target) + .iter() + .find(|node| node.id != target) + .unwrap() + .id; + let old = capture(&conn, sibling).unwrap().name; + rename(&mut conn, &mut project, sibling, NEW); + if restored { + rename(&mut conn, &mut project, sibling, &old); + } + let before = logical_rows(&conn); + assert!( + script_document_command::apply_generated_script_block(&mut conn, &generation, 230) + .is_err() + ); + assert_eq!(logical_rows(&conn), before); + } +} + +#[test] +fn duplicate_forged_wrong_clock_and_missing_selected_receipts_are_refused_atomically() { + let (mut conn, mut project, generation) = setup(false); + let target = generation.payload.target_binding.as_ref().unwrap().node_id; + let original = generation.payload.timeline_title_inputs.clone().unwrap(); + let sibling = original + .iter() + .find(|input| input.node_id != target) + .unwrap() + .node_id; + rename(&mut conn, &mut project, sibling, NEW); + let current = capture(&conn, sibling).unwrap(); + for receipt in [ + vec![original[0].clone(), original[0].clone()], + vec![TimelineTitleInput { + name: "Invented".into(), + ..original[0].clone() + }], + vec![TimelineTitleInput { + revision_event_id: current.revision_event_id, + ..original[0].clone() + }], + vec![current], + ] { + let mut forged = generation.clone(); + forged.payload.timeline_title_inputs = Some(receipt); + let before = logical_rows(&conn); + assert!( + script_document_command::apply_generated_script_block(&mut conn, &forged, 230).is_err() + ); + assert_eq!(logical_rows(&conn), before); + } +} + +#[tokio::test] +async fn targeted_title_prompt_and_acceptance_preserve_manual_text_and_refresh_lineage() { + let (mut conn, mut project, generation) = setup(true); + edit(&mut conn, &generation.payload.block, HUMAN); + let target = generation.payload.target_binding.as_ref().unwrap().node_id; + let sibling = project + .timeline + .siblings_of(target) + .iter() + .find(|node| node.id != target) + .unwrap() + .id; + let old = capture(&conn, sibling).unwrap(); + let before = materials(&conn); + rename(&mut conn, &mut project, sibling, NEW); + let command = request(&conn, &generation.payload.block); + let binding = script_impact_review::capture(&conn, &command.payload).unwrap(); + assert!( + binding + .timeline_title_previous + .as_ref() + .unwrap() + .contains(&old) + ); + assert!( + binding + .timeline_title_current + .as_ref() + .unwrap() + .iter() + .any(|input| input.node_id == sibling && input.name == NEW) + ); + let proposed = + crate::script_impact_prompt::preview_with_provider(&binding, |prompt| async move { + assert!(prompt.user.contains(HUMAN)); + assert!(prompt.user.contains(&format!( + "ORIGINAL CONSUMED TIMELINE TITLE ({}):\n{}", + sibling.0, old.name + ))); + assert!(prompt.user.contains(&format!( + "CURRENT CONSUMED TIMELINE TITLE ({}):\n{}", + sibling.0, NEW + ))); + let result: eidetic_core::ai::backend::GenerateStream = + Box::pin(futures::stream::iter(vec![Ok(PREVIEW.into())])); + Ok(result) + }) + .await + .unwrap(); + script_impact_review::record_proposal(&mut conn, &command, binding.clone(), proposed, 220) + .unwrap(); + assert_eq!( + materials(&conn), + before, + "preview must retain exact manual screenplay" + ); + let before_doc = + crate::script_store::load_document_projection(&conn, &generation.payload.block.document_id) + .unwrap() + .unwrap(); + accept(&mut conn, &command).unwrap(); + let after_doc = + crate::script_store::load_document_projection(&conn, &generation.payload.block.document_id) + .unwrap() + .unwrap(); + for (before, after) in before_doc.segments.iter().zip(&after_doc.segments) { + assert_eq!(before.segment, after.segment); + for (before, after) in before.blocks.iter().zip(&after.blocks) { + if before.block.id == generation.payload.block.block_id { + assert_eq!(after.block.text, PREVIEW); + } else { + assert_eq!(before, after); + } + } + } + let accepted = impact(&conn, &generation); + assert!(!accepted.needs_review); + assert_eq!( + recorded(&conn, accepted.generation_event_id).unwrap(), + binding.timeline_title_current + ); + rename(&mut conn, &mut project, sibling, "Next title"); + let next = impact(&conn, &generation); + assert_eq!(next.causes[0].input_excerpt.as_deref(), Some(NEW)); + assert_eq!( + next.causes[0].dependency_id, + dependency_id(accepted.generation_event_id, sibling) + ); +} + +#[test] +fn pending_preview_refuses_consumed_title_change_and_restore_without_any_write() { + for restored in [false, true] { + let (mut conn, mut project, generation) = setup(true); + let target = generation.payload.target_binding.as_ref().unwrap().node_id; + let sibling = project + .timeline + .siblings_of(target) + .iter() + .find(|node| node.id != target) + .unwrap() + .id; + rename(&mut conn, &mut project, sibling, NEW); + let command = preview(&mut conn, &generation); + rename(&mut conn, &mut project, sibling, "Later title"); + if restored { + rename(&mut conn, &mut project, sibling, NEW); + } + let before = logical_rows(&conn); + assert!(accept(&mut conn, &command).is_err()); + assert_eq!(logical_rows(&conn), before); + } +} + +#[test] +fn legacy_unknown_receipts_are_not_backfilled_after_title_edits() { + let (mut conn, mut project, mut generation) = setup(false); + generation.payload.timeline_title_inputs = None; + script_document_command::apply_generated_script_block(&mut conn, &generation, 210).unwrap(); + let event = impact(&conn, &generation).generation_event_id; + let target = generation.payload.target_binding.as_ref().unwrap().node_id; + let sibling = project + .timeline + .siblings_of(target) + .iter() + .find(|node| node.id != target) + .unwrap() + .id; + rename(&mut conn, &mut project, sibling, NEW); + assert_eq!(recorded(&conn, event).unwrap(), None); + assert!(impact(&conn, &generation).causes.is_empty()); + let inputs = preview_inputs(&conn, event).unwrap(); + assert!(inputs.previous.is_none() && inputs.current.is_none() && inputs.absent.is_none()); +} + +#[test] +fn deleted_title_sources_bind_owned_absence_and_acceptance_tracks_only_remaining_titles() { + let (mut conn, mut project, generation) = setup(true); + let target = generation.payload.target_binding.as_ref().unwrap().node_id; + let sibling = project + .timeline + .siblings_of(target) + .iter() + .find(|node| node.id != target) + .unwrap() + .id; + let old = capture(&conn, sibling).unwrap(); + project.timeline.nodes = timeline_node_store::load_nodes(&conn).unwrap(); + crate::timeline_node_delete_history::record_delete_timeline_node_history( + &mut conn, + &project, + &CommandEnvelope::new(DeleteTimelineNodeCommand { node_id: sibling }), + 220, + ) + .unwrap(); + let changed = impact(&conn, &generation); + let cause = changed + .causes + .iter() + .find(|cause| cause.dependency_id == dependency_id(changed.generation_event_id, sibling)) + .unwrap(); + assert_eq!(cause.reason, ScriptImpactReason::Deleted); + assert_eq!(cause.input_excerpt.as_deref(), Some(old.name.as_str())); + let mut command = request(&conn, &generation.payload.block); + command.payload.dependency_id = cause.dependency_id.clone(); + let binding = script_impact_review::capture(&conn, &command.payload).unwrap(); + assert!( + !binding + .timeline_title_current + .as_ref() + .unwrap() + .iter() + .any(|input| input.node_id == sibling) + ); + let deletion = timeline_node_store::latest_name_event(&conn, sibling, None) + .unwrap() + .unwrap(); + assert_eq!( + binding.timeline_title_absence_revisions.as_ref().unwrap(), + &vec![(sibling, deletion)] + ); + let before = materials(&conn); + script_impact_review::record_proposal( + &mut conn, + &command, + binding.clone(), + PREVIEW.into(), + 230, + ) + .unwrap(); + assert_eq!(materials(&conn), before); + accept(&mut conn, &command).unwrap(); + let accepted = impact(&conn, &generation); + assert!(!accepted.needs_review); + assert_eq!( + recorded(&conn, accepted.generation_event_id).unwrap(), + binding.timeline_title_current + ); +} + +#[test] +fn deleted_title_absence_edit_restore_delete_aba_refuses_pending_acceptance() { + let (mut conn, mut project, generation) = setup(true); + let target = generation.payload.target_binding.as_ref().unwrap().node_id; + let sibling = (**project + .timeline + .siblings_of(target) + .iter() + .find(|node| node.id != target) + .unwrap()) + .clone(); + let delete = |conn: &mut Connection, project: &mut Project| { + project.timeline.nodes = timeline_node_store::load_nodes(conn).unwrap(); + crate::timeline_node_delete_history::record_delete_timeline_node_history( + conn, + project, + &CommandEnvelope::new(DeleteTimelineNodeCommand { + node_id: sibling.id, + }), + 220, + ) + .unwrap(); + project.timeline.nodes = timeline_node_store::load_nodes(conn).unwrap(); + }; + delete(&mut conn, &mut project); + let command = preview(&mut conn, &generation); + timeline_command_history::record_create_timeline_node_history( + &mut conn, + &project, + &CommandEnvelope::new(CreateTimelineNodeCommand { + node_id: sibling.id, + parent_id: sibling.parent_id, + level: sibling.level, + name: sibling.name.clone(), + start_ms: sibling.time_range.start_ms, + end_ms: sibling.time_range.end_ms, + beat_type: sibling.beat_type.clone(), + }), + 230, + ) + .unwrap(); + delete(&mut conn, &mut project); + let before = logical_rows(&conn); + assert!(accept(&mut conn, &command).is_err()); + assert_eq!(logical_rows(&conn), before); +} diff --git a/docs/plans/agent-story-workflows/ancestor-notes-qualification.md b/docs/plans/agent-story-workflows/ancestor-notes-qualification.md new file mode 100644 index 00000000..f5aa1eb8 --- /dev/null +++ b/docs/plans/agent-story-workflows/ancestor-notes-qualification.md @@ -0,0 +1,39 @@ +# Consumed ancestor Notes propagation — terminal native evidence + +Hosted run [37721620953](https://github.com/MrScripty/Eidetic/actions/runs/37721620953) / job113130335009 is **terminal success**. Native assertions and root independent source/visual acceptance pass for this bounded synthetic gate. Root reports no blocking source finding. + +Frozen product `10a2b43ef2554799b92b155e3c49c63813c67d7a`, tree `06a0d52deefe2c946ebfc3da5d7b96523739ac5f`, branch `test/ancestor-notes-review-gap`. Implementation `e5fc35502a72c3e6f09b08f9c491ca8ecc0ad549`, tree `030c30f5dbbb83af8cadd11ce014ba8f839de4c5`; every application Rust/TS/Svelte file is unchanged by the contract-document successor. Frozen QA `c09b9921c5ebce0be0d25c32470b511653dc8273`, tree `a2bea55c5c6b428cf9d959b40e1b1a2184b3949c`, branch `test/ancestor-notes-native`; hosted admission permits exactly ten QA files and every application file is unchanged. + +## Behavior and validation + +Generation retains exact nonempty ancestor Notes actually supplied to its prompt and owned field revisions in the existing command/proposal/dependency history. Precise dependency identities identify affected Scene material; the existing review shows original/current Notes. Saving Notes and previewing preserve saved/manual/locked screenplay. Only explicit acceptance replaces the chosen block and refreshes its consumed receipts. Generation admission, preview storage and acceptance retain stale/ABA guards. Legacy absent or unowned receipts remain unknown. No new state store, endpoint, model or embedding dependency. + +Executed baseline c2659fa fails the known consumed-ancestor gap while the unrelated Act control passes. The same positive case is now an ordinary passing regression. Local and hosted core123/server556 (zero ignored), UI558/96, strict all-target/all-feature Clippy, typecheck0/0, lint/format/build and ONNX policy5 pass. Native driver53 passes. Twelve lineage and two runtime boundary cases cover precise identities, forged/duplicate evidence, delayed/ABA results, clearing, acceptance lineage refresh, locks/manual text, unrelated/non-Notes changes, legacy absence, deletion and synthetic failed streams. Local ONNX403 was not bypassed. + +## Actual native flow + +Native X11/AT-SPI controls and public commands edit/save manual B text, leave an exact unsaved F draft, and display Bible, timeline and screenplay. The unrelated Act Notes control produces no new ancestor-Notes cause; pre-existing review flags from other causes remain. The consumed Act edit is exactly ` Mara reveals the witness — 雨.\n\n `, replacing ` Mara conceals the witness — 雨.\n\n `. Actual A/B impacts identify the consumed ancestor; unrelated F has no cause. Pending targeted review preserves exact manual B, original/current Notes and all saved material. Ordinary edit/restore ABA refuses old acceptance without canonical writes. Rejecting it, requesting a fresh preview and explicitly accepting updates only B; A retains its ancestor cause. Unrelated saved text/F draft, placement and Bible remain intact. Six explicitly synthetic HTTP/SSE provider calls (four initial generation/recap, two previews) pass exact prompt guards. Real-model quality is unqualified. + +Actual native process PID25058 / X11 window2097155, executable SHA256 `e0d08a614ea8b7c29d35dd067546eda7be427b1dc6cd2e70f2a7aca9bb179312`. + +## Preserved artifact and screenshots + +[Artifact 11526418204](https://github.com/MrScripty/Eidetic/actions/runs/37721620953/artifacts/11526418204): 2524346 bytes, ZIP SHA256 `b068ff3aba6556eac820fac2495638e67c17d82ccfaa73d0bb53f89ee71625f6`, expires `2026-10-11T03:24:58Z`. Connector ZIP `file_000000004200823097c91c47eadd0a2c`. Includes five original PNGs, five JPEG85 derivatives, full evidence JSON and sanitized logs. Root independently transferred the archive, visually inspected all five images, and verified PNG/JPEG/ZIP and UI source hashes. Checksums below match the hosted native receipts. + +| Native state | Original PNG | Hosted SHA256 | +| --- | --- | --- | +| 00-unrelated | `ancestor-notes-00-unrelated.png` | `d212e9c2b6a1237ba566b253f8c5aa2e94d5b6a1b0df04d9cae4176efef4dc1e` | +| 01-needs-review | `ancestor-notes-01-needs-review.png` | `0462be04a435195b62d11c3da6ba724977cf349cfcaa911bd7a533e35160fdc9` | +| 02-pending | `ancestor-notes-02-pending.png` | `1eba7f21e84fa781683a2af1e37a8296ace13aee9d523f705f47c1e13fa065e6` | +| 03-stale | `ancestor-notes-03-stale.png` | `949fa41399ded344ffd818dc3a46dfeab53b12debbed38b72b6cdff95e8780be` | +| 04-accepted | `ancestor-notes-04-accepted.png` | `b32c502b376ff753f2cb3603425a1c93099ce9cb507c5bde19d52f3f69438e2b` | + +## Preserved attempts and independent acceptance + +- Run37717759278 / QA24c6b26: standard Azure package installation exceeded its original 10-minute timeout; no runtime/UI execution. Only measured setup/job timeout limits changed. +- Run37718783937 / QAdb1c886: public fixture refused a Scene directly under the unrelated Act; no app open/model calls. Artifact11525158168 retains partial logs/receipt. QA added the required Sequence, with actual public-service setup and canonical hierarchy/owned-history self-tests. +- Run37720332948 / QA92287c3: actual unrelated control and exact consumed Act edit/preservation passed; old QA receipt filtered out ancestor dependencies. Artifact11525547372 retains original control/failure PNGs and receipts. Actual-wrapper regression fails before and passes after the scoped QAc09b992 filter repair. Product source stayed unchanged. + +The executor archive transfer was refused by its proxy with `Tunnel connection failed: 403 Forbidden`, including supported explicit network access. Root independently accessed the actual archive through authorized tools, verified its hashes and visually inspected all five native images. Root also independently accepted exact product10a2b43/tree06a0d52 and implementatione5fc355 with no blocking source finding for the bounded synthetic native gate. This root review is separate from the author audit; no author visual-inspection claim is made. The pending-preview JPEG was delivered by root. Publication is a source-only stacked draft; no merge or real-model quality acceptance is implied. + +Prior selected-Notes closure run37713651568 and provenance840f459 remain preserved. Read-only GitHub inspection confirms PR19–22 remain open drafts; no ancestor PR existed before this accepted publication. No main/PR19–22 merge, reviewer request, credentials change or paid service was made. Raw logs, full machine receipts and this review packet remain outside source Git. diff --git a/docs/plans/agent-story-workflows/ancestor-notes-review-gap.md b/docs/plans/agent-story-workflows/ancestor-notes-review-gap.md new file mode 100644 index 00000000..41b62423 --- /dev/null +++ b/docs/plans/agent-story-workflows/ancestor-notes-review-gap.md @@ -0,0 +1,83 @@ +# Consumed ancestor Notes review gap + +Status: implemented, source-tested and independently accepted for the bounded synthetic native gate. Based on qualified Notes prompt +source `c5fad5cbca6f1d3ba42bcdb66c833ac1d513dbf4` and provenance head +`840f459cf34ea1856c5eaf127698663ff07d22ff`, on separate +`test/ancestor-notes-review-gap`. Baseline reproduction is preserved at +`c2659fa409c7043d9a831f0b1b03ed92d49b9e74`; QA-host changes remain separate. + +## Evidence before implementation + +`timeline-notes-review.md` explicitly leaves ancestor/sibling prose as a separate +follow-up. `build_generate_request` includes the actual ancestor chain, and +`prompt_format::build_chat_prompt` supplies each ancestor's Notes in CONTEXT +HIERARCHY. `GenerationInputs` and `GenerateScriptBlockCommand` carry the selected +target receipt, but no consumed ancestor Notes receipt. Existing +`timeline_notes_lineage::from_target`, `recorded` and `dependency` account for the +selected target only. The targeted proposal binding likewise carries one selected +Notes input. Ancestor prompt consumption therefore has no owned Notes dependency. + +Two real server regressions use canonical SQLite, the existing context attachment +and generation writer, and public `set_timeline_node_notes`. The fixture selects +an actual Scene under an Act, saves exact ancestor Notes +` Mara conceals the witness — 雨.\n\n `, verifies that exact value appears in the +actual formatted prompt, and proves distinct unrelated Act Notes do not appear. +The saved screenplay output is explicitly synthetic. There is no model call or +claim about narrative quality. + +- Before implementation, changing the consumed Act Notes to + ` Mara reveals the witness — 雨.\n\n ` failed precisely at + **“Exact ancestor Notes supplied to generation changed without downstream scene review”**. + Prompt consumption, fresh-before-edit review state and saved-material + preservation assertions pass first. The test also requires the eventual review + cause to identify the actual ancestor and original exact Notes. +- Changing the unrelated Act Notes passes and preserves the review projection. +- Both compare every saved segment, block, span, lock, placement and block revision; + the public edits do not replace saved screenplay. + +Local locked server execution compiled through the existing prepared dependency +setup. No ONNX download bypass, alternative library, new cache, embedding +dependency or credential/configuration change was introduced. Raw baseline logs +stay outside Git under `/workspace/scratch/notes-prompt-preview/`. + +Current locked all-target suite: **123 core / 556 server pass**, with **zero ignored** +cases. Full UI **558 tests / 96 files** and production build pass. Strict +all-target/all-feature core/server +Clippy, Rust format, decision traceability and **5 ONNX policy tests** pass. + +## Reproduce and maintain + +The baseline commit marked the positive known-gap regression ignored in ordinary +CI and executed it explicitly to prove failure. The implementation removes that +annotation: the same public-command regression is now an ordinary passing test. + +```sh +cargo test --locked -p eidetic-server \ + ai_generation_runtime::runtime_tests::public_consumed_ancestor_notes_edit_marks_saved_scene_for_review \ + -- --exact --nocapture +cargo test --locked -p eidetic-server \ + ai_generation_runtime::runtime_tests::public_unconsumed_act_notes_edit_preserves_saved_scene_review_state \ + -- --exact --nocapture +``` + +## Implemented bounded propagation + +The generation read retains only nonempty ancestor Notes actually supplied to generation, with their +owned field revision captured before provider I/O. It reuses command/proposal JSON, +existing semantic dependencies, field-history validation and impact projection. +Original/current ancestor evidence is shown in the existing targeted review, preserving +all saved/manual text until explicit acceptance, with stale/ABA and draft guards. +Accepted proposals carry the newly consumed receipts through the +existing lineage mechanism. Legacy missing receipts remain unknown; never +backfill consumption from current prose. + +Do not broaden this slice to sibling prose, names, beat types, new hierarchy or +membership, project switching, Pumas publication or model-quality evaluation. +Twelve lineage regressions plus the two runtime boundary cases exercise exact +identity, equal Notes on distinct ancestors, stale initial reads, duplicate/forged +evidence, delayed generation, acceptance lineage refresh, clearing, pending +storage/acceptance ABA refusal, unrelated and non-Notes changes, locked manual +text, legacy absence, deletion and provider failure. Existing read-model schema +and acceptance flow are retained; there is no automatic regeneration. + +Actual native run37721620953 passes at frozen product10a2b43 / QAc09b992. Root independently reviewed the source, visually inspected all five captures and verified artifact/UI source hashes, with no blocking finding for this bounded synthetic gate. The unrelated Act control gains no new ancestor-Notes cause; pre-existing causes remain. See `ancestor-notes-qualification.md` for exact identities, tests, captures and preserved attempts. Real-model quality remains unqualified. Selected Notes prompt run37713651568 and its source-bound artifacts stay preserved. Publication is a source-only stacked draft; parent retains PR19-22/main merge decisions. diff --git a/docs/plans/agent-story-workflows/arc-description-applicability.md b/docs/plans/agent-story-workflows/arc-description-applicability.md new file mode 100644 index 00000000..763bf7bc --- /dev/null +++ b/docs/plans/agent-story-workflows/arc-description-applicability.md @@ -0,0 +1,87 @@ +# Known-empty tagged arc description applicability + +Separate `feat/arc-description-applicability`, based on accepted PR23 provenance +`e9abf3efae6f6dc9e9d4b610cb4e01f817d487f7`. PR19–23/main remain unmerged. +Baseline `1feeee8cbd79fce71b90aed78f5d95caeb504d44` reproduces the boundary. +Implementation now retains applicability through existing generation/proposal +history and graph dependencies. Native functional qualification passes run37726307798 against product cac013f / +QA1989909. Independent source/visual review remains for the parent; real-model +quality is unqualified. See `arc-description-qualification.md`. + +The consumed-arc roadmap explicitly leaves previously unconsumed empty +descriptions separate. ArcDetail already commits exact typed descriptions through +the public metadata writer. Generation omits an empty Description from supplied +fields, so filling it creates no old description dependency. The legacy +`unconsumed_empty_description_is_not_backfilled_after_an_edit` test protects that +honest unknown behavior and must remain. + +Two new real runtime tests select a template-tagged Scene, publicly commit an +owned empty Description, attach canonical generation context, prove the actual +prompt omits that description, and persist explicitly synthetic saved output. +The public writer then fills exact ` Mara chooses exile — 雨.\n\n `. +Fresh canonical prompt inclusion and saved-material preservation pass, but the +positive fails at **“Known-empty tagged arc description became supplied without +precise downstream Scene review”**. A control publicly repeats empty clearing and +fills an untagged arc; it passes and preserves saved material/review state. +Raw logs remain outside source Git. No model call or local ONNX403 bypass. + +The positive was temporarily ignored only in baseline CI and executed explicitly +with `--ignored --exact`; implementation removes that annotation. The same case +now passes as an ordinary test. The legacy no-backfill test remains unchanged. + +```sh +cargo test --locked -p eidetic-server \ + ai_generation_runtime::runtime_tests::public_known_empty_arc_description_entry_marks_saved_scene_for_review \ + -- --exact --nocapture +cargo test --locked -p eidetic-server \ + ai_generation_runtime::runtime_tests::public_known_empty_arc_clear_and_untagged_description_preserve_saved_scene \ + -- --exact --nocapture +``` + +## Bounded decision + +Capture applicability evidence for known-empty Description fields on actually +tagged arcs in the same generation snapshot. Retain exact arc/field identity, +empty value and owned field clock through existing command/proposal JSON and +semantic dependencies. Omitted prose is not consumed prose; missing legacy +receipts and unowned history remain unknown. Do not backfill from current tags. + +Filling an applicable known-empty description should identify affected Scene +material, show original omission/current exact text in existing targeted review, +and supply the current direction to its preview. Saving and previewing preserve +manual text, spans, locks, placement and drafts. Only explicit acceptance updates +the chosen block and refreshes its actual consumption. Keep writer recapture, +stale/ABA refusal and negative clear/unrelated controls. Qualify actual ArcDetail +typing with Bible, timeline and screenplay visible, then obtain independent +acceptance before publishing a source-only stacked draft. Synthetic responses +must remain labelled; real-model quality is unqualified. + +No new canonical store, schema, provider or embedding dependency. Arc assignment +editing, broader tag membership, hierarchy inheritance, fictional-time inference, +automatic Bible extraction and project switching stay outside this slice. + +## Implemented source contract + +`arc_description_applicability` uses optional `StoryArcFieldInput` collections, +empty Description values and owned history. Missing receipts and unowned reads +remain unknown. Generation admission validates the selected tag identities and +omitted fields under the writer before saving. Each owned applicability receipt +creates an existing revision-bound ScriptSegment-to-StoryArcField dependency, +with an explicit omitted-prose rationale. Impact checks that original applicability, +current assignment and newly available nonempty prose; no broad current-state +backfill is performed. + +Targeted review captures original omission and fresh current fields. The provider +receives exact new description and manual target text. Proposal storage and +acceptance recapture the same receipt; clear/restore ABA refuses old work without +writes. Clearing withdraws the entry cause. Explicit acceptance refreshes the +chosen block's ordinary consumed Description lineage; another consumer keeps its +own precise cause and exact text. + +Maintained regression coverage includes real graph-row assertions, public +Arc metadata entry/clear/untagged controls, forged/duplicate/missing receipts, +legacy unknown history, unrelated metadata, multiple consumers, locked manual +spans and partial provider failure. UI evidence distinguishes omitted prose from +consumed fields and renders exact Unicode/whitespace and revisions. Synthetic +test outputs are labelled. Native qualification must exercise ordinary ArcDetail +typing with Bible, timeline and screenplay visible before publication. diff --git a/docs/plans/agent-story-workflows/arc-description-qualification.md b/docs/plans/agent-story-workflows/arc-description-qualification.md new file mode 100644 index 00000000..ebdc4a89 --- /dev/null +++ b/docs/plans/agent-story-workflows/arc-description-qualification.md @@ -0,0 +1,110 @@ +# Known-empty tagged arc Description native qualification + +The bounded feature and native functional gate passed. Independent source/visual +review remains for the parent; this author report does not claim that acceptance. +Real-model quality remains unqualified: all six HTTP/SSE replies were explicitly +synthetic. No new provider/model dependency or local ONNX403 workaround. + +## Exact sources + +- Parent accepted PR23 documentation head: e9abf3efae6f6dc9e9d4b610cb4e01f817d487f7. +- Public failing reproduction: 1feeee8cbd79fce71b90aed78f5d95caeb504d44, + tree4c84f49dc2a7d1afb52dd00a9174352057c82b1f. +- Frozen application/implementation: cac013fa85a890d5113399e0ce626aeb03c9457d, + tree170341fdeccdeb4654bbe279b1787c8cf0660f46. +- QA: 1989909216c31052bea2c0c4d055f9242b21b993, + tree1acc67ccb36531a6404ab4f05515fb90ab54b714. +- Application binary SHA256: + fdd7206b3288493dd115a168c329e6464534c5565eadfaa16fc7db90c1f05e81. + +The QA branch differs from frozen application source in exactly the enumerated +ten qualifier files. Its application Rust/TS/Svelte files are unchanged. This +report successor changes documentation only. PR19–23/main remain unmerged; +parent handles independent review, PR publication, merges and Library delivery. +No new PR or external reviewer request was made for this feature. + +## Executed gate + +Native run [37726307798](https://github.com/MrScripty/Eidetic/actions/runs/37726307798), +job113145156499, attempt1 completed successfully. Normal locked desktop and public +fixture builds, core123/server566 with zero ignored tests, UI562/96, strict +core/server all-target/all-feature Clippy, typecheck0/0, lint/format/build, +driver55 and ONNX policy5 passed. Normal milestone commit hooks passed. + +The baseline positive failed at the documented missing precise downstream review, +while actual fresh prompt inclusion and saved material were preserved. The same +positive is now an ordinary passing test. The legacy unconsumed-empty Description +no-backfill regression remains unchanged and passing. Initial local full tests +hit17 existing tests in a read-only host data directory; the ordinary isolated +XDG application-state route passes all123/566. Raw attempts remain outside Git. + +## Actual authoring sequence + +Public setup preserves actual template Scene tags, publicly writes an owned empty +Description and creates two genuine synthetic consumer generations/recaps. Each +persisted generation retains omitted-field applicability and its real graph row, +without claiming empty description prose was supplied. A separate local public +setup check executes zero provider calls; a full public fixture check executes +four labelled synthetic calls and verifies both persisted receipts. Local UI is +not claimed; the hosted run executes the actual native application. + +The native author manually saves exact B screenplay +` Exact authored B: Eli keeps his whistle — 雨.\n\n ` and retains exact unrelated +F draft `Unrelated draft: Eli pockets a brass whistle.\n\n`. +Ordinary Bible selection leaves the real Mara inspector visible while the left +sidebar uses ArcDetail, with timeline and screenplay visible. An unrelated arc +Description fill adds no target applicability cause and preserves saved material. +Ordinary ArcDetail keys then commit exact +` Mara chooses exile — 雨.\n\n ` through the existing debounced public writer. +The canonical projection identifies both A/B consumers with distinct precise +Description applicability dependencies; no unrelated consumer acquires that cause. + +Existing Preview update produces a pending proposal showing original omission, +exact current Description and owned field clocks. Native text geometry verifies +current Description glyphs and the omission label inside the Script pane. Saved +manual text, blocks/spans/locks, placement, Bible and unrelated draft stay exact. +Ordinary clearing withdraws the newly supplied-prose cause and old pending Accept +refuses without recorded writes. Exact restoration advances the field clock; +old acceptance still refuses ABA. The writer rejects the old proposal, requests +one fresh synthetic preview and explicitly accepts B. Only B changes; A retains +its applicability cause and saved text, F retains saved text/draft, and placement +and Bible remain exact. B refreshes ordinary consumed Description lineage. + +The inherited fixture checkpoint contains the generic words “unconsumed fields”; +this arc scope proves omitted Description applicability using the retained actual +tag/field-history receipts. It does not claim extra unconsumed Bible fields were +added in this fixture. + +## Screenshot artifact + +[Artifact11527798897](https://github.com/MrScripty/Eidetic/actions/runs/37726307798/artifacts/11527798897) +contains six original1920×1440 PNGs, six quality85 JPEG display derivatives, +source-bound capture-evidence.json and sanitized app/host logs. Size2474528 bytes; +GitHub ZIP digest b4e9e539cced62a3fc88c34dd2458f5d90b0a096e8e11859c77a3cbfb8246349. +Expires2026-10-11T04:24:35Z. Connector ZIP reference: +file_0000000058ac81f7aa3ff7008bee0ebf. + +The native receipt binds every capture to window2097155/PID25144. Executor artifact +transfer is blocked by proxy CONNECT403; the author has not visually inspected +these images or independently verified ZIP bytes. Parent can materialize the +connector archive for visual review and hash verification, as on the preceding +qualification. Full evidence and originals stay outside source Git. + +| Original PNG | Native-receipt SHA256 | +| --- | --- | +| arc-description-00-unrelated.png | 80ee27399a38700d26889ee69c58df8161de52c05ad2a7b487fa8db58eed36d8 | +| arc-description-01-needs-review.png | ec1bece988f30df8e558a86eae40e4d93adebbb6353c5edb901ae3dfa47ff986 | +| arc-description-02-pending.png | 9eed4e757b67f72a9c1fad4b71caa5f3d805dde46077d85a0208876bd1861a7e | +| arc-description-03-cleared-refused.png | 9e75c779ea90ba29798ac71c2201e340ce1aea2306e7d97d7ebe1a998cd2a6e1 | +| arc-description-04-stale.png | 2bd8e63795a67c6566c13f28b74266db92a2f128fc170a2cba4b287fa72fa61d | +| arc-description-05-accepted.png | 143f85c27227c957d6af1070305b14194056fea11009eb19db14281dddb7707e | + + +| JPEG display derivative | Native-receipt SHA256 | +| --- | --- | +| arc-description-00-unrelated.jpg | e3bcf4d3a29422c2f215ae18d672f32436fe9695b3cd0808fba72fc7af9aea13 | +| arc-description-01-needs-review.jpg | 9e2741f452d5daa3f899a11da10b8542625f0c683016247808bad968336b3bc3 | +| arc-description-02-pending.jpg | 82a5b99cb4498a9b85ef8c15081826f3303b64f14e08584ad1c82e249f47258f | +| arc-description-03-cleared-refused.jpg | 240652142c7b83c703b827c9f05d473e039d8bc7f56002827e4fa7fb12394dcc | +| arc-description-04-stale.jpg | 5411d9d4e0b889895ef053ca6388fe39248e99269f1ebabbffddf174b3080724 | +| arc-description-05-accepted.jpg | 971c56f5c0ead139fadab94a82b182cb248b3a119d1b68b7a0b240e9ae5156c6 | diff --git a/docs/plans/agent-story-workflows/execution-ledger.md b/docs/plans/agent-story-workflows/execution-ledger.md index 4af45307..4d891702 100644 --- a/docs/plans/agent-story-workflows/execution-ledger.md +++ b/docs/plans/agent-story-workflows/execution-ledger.md @@ -1935,3 +1935,150 @@ clear/deletion/ABA, writer recapture, target edits, both locks and failed stream five added UI cases cover exact source evidence and event refresh/draft retention. Hosted native qualification is prepared separately, not yet claimed. Synthetic HTTP fixtures establish workflow behavior, not real-model narrative quality. + +## 2026-10-08 — Exact committed Notes prompt preview successor + +Turn startup was verified at 00:27 UTC against public state before writes. +Reviewed Notes/report branch remained `5a8670d`; PR19/20/21 were open drafts with +their original heads and no equivalent Notes draft existed. Published PR22 as a +draft stacked on `feat/manual-edit-bible-reconciliation-review` (PR21), without +merging or changing those PRs. Root independently verified a31/QA950 source hashes +and all four native37688763677 review/pending/stale/accepted captures and delivered +the pending-view JPEG; the earlier executor download blocker is historical. + +The next bounded documented Memory read / Projection propagation gap is the +selected raw AI prompt. `BeatEditor` supplies canonical Notes, but its existing +request owner deduplicated only node/script revision. The backend public +`ai_context_preview` already reads canonical SQLite. On exact5a, all six new +Notes-only regressions failed while seven maintained cases passed. Separate +`fix/notes-prompt-preview-custody` adds exact committed Notes to that cache identity, +retaining request-ID stale/ABA/loading guards and ordinary Refresh. No saved +script/story, draft, proposal, acceptance, backend or Pumas endpoint changes. + +New focused lifecycle cases and full557 UI/96-file tests pass, typecheck zero +errors/warnings, lint/build pass. Native qualification remains the next gate; +context responses must be real public reads with an explicitly labelled held +return, never fabricated prompt text. Generated originals/JPEG85/logs stay outside +Git. Pumas modality publication and project-switch recovery remain separate. + + +## 2026-10-08 — Exact Notes clear/restore qualification closure + +Continued existing run37713651568 without restart. Frozen productc5/treeade8 and +separate QA11b/tree35f pass all hosted source-admission/UI/dependency/build and +core123/server542/strict-Clippy gates, driver50/policy5 and native exact Notes +refresh/held real return refusal/clear/exact restoration. Five native PNGs and +JPEG85 displays are artifact11523381856, ZIP SHA-256 +b4f95daaf9f11d3bcd5043c85249384079f884dcefc4d19c40cd1560bc07e6ff, +expires2026-10-11T01:44:58Z. Four labelled synthetic fixture calls only; real +public context reads make no new inference call. Saved screenplay/placement/ +Bible and the unrelated F draft stay intact. Independent c5 source/test/integration +review found no issue within the selected Notes request-owner scope. + +Preserved three qualifier failures: clipped original glyphs, empty input lacking +BackSpace, and broad restore selector reaching an unsaved Bible Summary draft. +Only separate QA files changed; exact application files stayed frozen. Native +machine receipts and full logs are outside Git. Personal visual inspection remains +blocked by executor transfer proxyHTTP403; retain that gap and exact artifact +identity. See notes-prompt-preview-qualification.md. No PR19-22/main merge, no +external reviewer request, credential change or paid model/service call. + +## 2026-10-08 — Consumed ancestor Notes boundary reproduced + +After closing exact Notes prompt provenance at840f459, selected the documented +ancestor prose follow-up. Separate test/ancestor-notes-review-gap retains qualified +application code and adds two actual runtime regressions. Canonical public Notes +writes and the real formatted generation prompt prove consumption of exact Act +Notes and exclusion of unrelated Act Notes. The positive reproduction fails at +missing downstream Scene review; the negative control passes. Both preserve saved +text/spans/locks/placement/revisions. Output is explicitly synthetic, no model call. +The known positive is marked ignored for ordinary CI and run explicitly for the +baseline; this is a test milestone, not a completed feature. Existing locally +prepared locked Rust dependencies compile without a new ONNX bypass. See +ancestor-notes-review-gap.md for commands, evidence and bounded reuse contract. +Locked all-target core123/server543 pass with the one known reproduction ignored; +strict all-target/all-feature Clippy, Rust format, traceability and policy5 pass. + + +## 2026-10-08 — Owned ancestor Notes propagation implementation + +Continued the verified c2659fa boundary on its separate branch. Canonical prompt +capture now binds nonempty consumed ancestor Notes and owned field clocks before +provider I/O. Existing generation command JSON, semantic graph dependencies and +proposal bindings retain exact node identities and original/current evidence. +Review uses the existing preview/explicit acceptance path; writer-lock checks +reject changed/cleared/ABA or forged generation evidence. Acceptance refreshes +ancestor lineage without touching unrelated material. Missing legacy consumption +remains unknown. No separate store, endpoint, model or embedding dependency. + +The executed failing public edit regression is an ordinary passing test. Twelve +lineage cases cover distinct identities, stale read, forgery, delayed generation, +manual/locked text, explicit acceptance refresh, clearing, preview/acceptance ABA, +unrelated/non-Notes events, deletion, legacy absence and synthetic provider failure. +Locked core123/server556 (zero ignored), full UI558/96, strict Clippy and production +build pass. Source review is followed by separate hosted native qualification; +prior Notes closure evidence and PR19-22/main remain preserved and unmerged. + + +## 2026-10-08 — Independent ancestor Notes native acceptance + +Frozen product10a2b43/tree06a0d52 (implementatione5fc355) and QAc09b992/treea2bea55 +pass native run37721620953/job113130335009 without a restart. Hosted core123/server556 +(zero ignored), UI558/96, strict Clippy, typecheck0/0, lint/format/build, driver53 +and policy5 pass. Exact ordinary Act Notes edit, two precise consuming Scene +causes, original/current pending evidence, manual text/draft preservation, +edit/restore ABA refusal and fresh explicit acceptance pass. Only B updates; +A retains its cause. The unrelated Act gains no new ancestor-Notes cause, while +pre-existing review flags from other causes remain. Six labelled synthetic +HTTP/SSE calls; real-model quality is unqualified. + +Root independently accepted exact source and visually inspected all five native +captures, verifying PNG/JPEG/ZIP and UI source hashes. No blocking source finding +for this bounded gate. Root transfer worked despite the executor's preserved +proxy403; author audit is separate and no author visual claim is made. +Artifact11526418204/ZIPb068ff3aba6556eac820fac2495638e67c17d82ccfaa73d0bb53f89ee71625f6 +expires2026-10-11T03:24:58Z; pending-preview JPEG delivered by root. Three failed/ +partial attempts and prior selected Notes closure evidence stay preserved. +See ancestor-notes-qualification.md. This successor changes documentation only; +application source stays byte-identical to qualified10a2b43. Source-only stacked +draft publication is authorized; no main/PR19-22 merge or external reviewer request. + + +## 2026-10-08 — Known-empty tagged arc Description applicability + +Separate `feat/arc-description-applicability` starts at accepted PR23 documentation +head e9abf3e. Baseline1feeee8 reproduces public owned-empty Description entry with +no downstream review while fresh prompt inclusion and saved material remain exact; +public clear/untagged control passes. The positive is now an ordinary passing test. +Optional applicability receipts reuse StoryArcFieldInput, generation/proposal JSON, +owned field revisions and existing graph dependency rows. Omitted prose is labelled +separately from consumption. Filling identifies precise Scene consumers; clearing +withdraws the entry cause. Writer stale/ABA checks preserve saved manual material +and locks. Explicit acceptance refreshes only the chosen consumer's normal +Description lineage; legacy missing/unowned history remains unknown. + +Maintained checks pass core123/server566 with zero ignored tests, UI562/96, +strict core/server all-target/all-feature Clippy, typecheck0/0, lint/build and +ONNX policy5. A first Rust run failed17 existing filesystem tests because host +application data is read-only; the ordinary isolated XDG application-state route +passes the full unchanged suite. Logs remain outside Git. No inference or ONNX403 +bypass. Native ArcDetail typing, screenshots and independent review remain pending; +PR19–23/main remain unmerged. See arc-description-applicability.md. + + +## 2026-10-08 — Arc Description native functional gate passed + +Frozen application cac013f/tree170341fd and QA1989909/tree1acc67cc pass hosted +run37726307798/job113145156499 on the first attempt. Standard locked native build, +core123/server566 zero ignored, UI562/96, Clippy/typecheck0/0/lint/format/build, +driver55 and ONNX policy5 pass. Ordinary ArcDetail keys commit exact Unicode and +whitespace direction, expose two precise Scene consumers, preserve manual B and +unrelated F draft, show original omission/current prose in pending review, refuse +clear/restore stale acceptance and explicitly update B alone. Six actual native +PNG captures and JPEG derivatives remain artifact11527798897, outside source Git. +All six provider calls are labelled synthetic; real-model quality is unqualified. +Executor artifact transfer hits proxy CONNECT403, so author visual inspection and +ZIP-byte verification are not claimed. Parent retains independent source/visual +review and PR/merge/Library delivery. No new PR, external reviewer, credential or +paid service. See arc-description-qualification.md for complete source/hash/test +receipts. This successor changes documentation only; PR19–23/main stay unmerged. diff --git a/docs/plans/agent-story-workflows/notes-prompt-preview-qualification.md b/docs/plans/agent-story-workflows/notes-prompt-preview-qualification.md new file mode 100644 index 00000000..bb8aa8dc --- /dev/null +++ b/docs/plans/agent-story-workflows/notes-prompt-preview-qualification.md @@ -0,0 +1,114 @@ +# Exact committed Notes prompt custody qualification + +[Hosted run 37713651568](https://github.com/MrScripty/Eidetic/actions/runs/37713651568) +**passed**, including ordinary exact Notes editing, held older real-context return +refusal, ordinary clear and exact restoration. Saved screenplay text, spans, +segments, locks, timeline placement, Bible facts and an unrelated F draft are +preserved. This is native application assertion/capture evidence; personal visual +inspection remains blocked by executor artifact-transfer HTTP403. + +Application source: `c5fad5cbca6f1d3ba42bcdb66c833ac1d513dbf4`, tree +`ade8c69ff296285c3b60bc4bdd3f0dfcbde2ec6a`, separate +`fix/notes-prompt-preview-custody`, based on PR22 Notes/report head +`5a8670d95e44915426986faa76b5d1739fc0e036`. Frozen QA: +`11b60a92c6fed0040f34c3a153329b3f8c7af826`, tree +`35f3f4983680e1c255090fe6fc7921770c4aa326`, separate +`test/notes-prompt-preview-native`. The exact QA delta is ten enumerated files; +the hosted admission check confirms every application file is unchanged. +PR19/20/21/22 and main are untouched and unmerged by this work. + +## Concrete gap, implementation and independent review + +The public Notes command emits `TimelineChanged` and `NodeUpdated`. Their UI +handlers refresh canonical selected Notes and screenplay impacts without advancing +`scriptDocumentProjectionState.contextRevision`. `BeatEditor` supplies exact Notes +to the existing context request owner, which previously deduplicated on selected +node and script revision only. Six new regression cases fail on exact `5a8670d` +while seven maintained cases pass. The existing `ai_context_preview` public read +already loads canonical SQLite; no new endpoint, model or dependency is needed. + +The request owner now includes exact nonempty committed Notes in its identity. +A change clears displayed old context and starts the existing public read. +Invalidation clears that identity. Request-ID and selected-node admission still +protect success, failure and loading finalization. Manual Refresh and unchanged +identity deduplication are retained. No saved text, draft, proposal or acceptance +owner changes. + +Independent review of exact `c5fad5c`, its thirteen lifecycle cases and the +`BeatEditor` / public-read / command-event integration found no issue within this +bounded scope. Tests exercise whitespace-only differences, Unicode, same-node +Notes ABA success/failure, overlapping Refresh, failed fresh reads, clearing, +selection changes and stale finalizers. Broader metadata/context membership, +project switching and real-model quality remain separate. + +## Verified gates + +- Local focused lifecycle plus selected-inspector events: **34 tests / 2 files**. +- Full UI, repeated hosted: **557 tests / 96 files**; typecheck **0 errors / 0 warnings**, + lint, format, production build and labelled QA host build pass. +- Hosted locked core **123** / server **542** tests and strict all-target/all-feature + Clippy pass. Local ONNX403 was not bypassed; runtime/service execution uses the + established hosted dependency route. +- Native-driver **50** and ONNX no-download-policy **5** tests pass. +- Normal commit hooks pass; logs and machine receipts stay outside source Git. + +| Frozen application file | SHA-256 | +| --- | --- | +| `ui/src/lib/components/editor/contextRequestLifecycle.ts` | `bead79a6cb21b0ab519a19f142601485356ca6bf90ad209e91f23e5b46dac9e6` | +| `ui/src/lib/components/editor/contextRequestLifecycle.test.ts` | `77892744bdcf52e8ebb7701da45bf0cdf5e4fe374d5d441eb9fdc82211e3e0ab` | + +## Native route and exact outputs + +Ordinary native controls select B, open Raw AI Prompt, request Refresh, edit Notes, +clear and restore them. The exact public saved value is +` Mara reveals the witness — 雨.\n\n `. The clearly labelled QA seam holds the +unmodified result of the real public context read; it fabricates no prompt. +`untrack` prevents qualifier state from becoming a production editor dependency. +Native [AT-SPI text-range scrolling](https://github.com/GNOME/pyatspi2/blob/master/pyatspi/text.py) +reveals the actual Notes before glyph bounds are checked. Exact text is read from +the real User Prompt, excluding the QA receipt textarea. No DOM/IPC injection or +direct database write is used. Read-only canonical material/history comparisons +and actual native draft controls establish preservation and late-return refusal. + +Four explicitly synthetic HTTP/SSE fixture responses create two saved consumers; +all are accepted by the fixture's exact-prompt guards. Context preview makes no +additional inference call. Real-model narrative quality is unqualified. + +[Artifact 11523381856](https://github.com/MrScripty/Eidetic/actions/runs/37713651568/artifacts/11523381856): +**2154758 bytes**, ZIP SHA-256 +`b4f95daaf9f11d3bcd5043c85249384079f884dcefc4d19c40cd1560bc07e6ff`, +expires **2026-10-11T01:44:58Z**. Connector ZIP reference: +`file_00000000e8308230a57a6c1fba103549`. It preserves five lossless native PNGs, +five JPEG quality85 display derivatives, capture JSON and two sanitized logs. +Native window PID24767 / X11 window2097155 belongs to the actual application; +its executable SHA-256 is `dfc065ef2c1147dff16ef6ba4b55afe6e24759796087d75251bc3f0bcbd488b5`. + +| State | Original PNG | Original SHA-256 | JPEG85 SHA-256 | +| --- | --- | --- | --- | +| Original context | `notes-prompt-01-original.png` | `a87a7a36ace6dba6e63604d90a59f7523965c794b2417669a210d0c85f4aee76` | `00b858b37d639da0ecc925b26cda125f2ee7fc532563877be0a1fc66b06ed814` | +| Fresh while old return held | `notes-prompt-02-fresh-old-held.png` | `2fe7b9cd6e9ae659a4a8566a5c85c92f33612d565bef016e4d3bc6f99961442d` | `2b0ef7a6c8d4097f5a0d2548dfa69d560d9a6e9e2530d38431d256486067c241` | +| Late return refused | `notes-prompt-03-late-return-refused.png` | `63012c5f6c4301f5efb0f5613dbdb99491cd0cc2c9d3c14491b7d5747a4f2daa` | `a49ba85c473f9bdac7bf62711f3ddf79da5088bc0c2049d3f3d1ed5f15630345` | +| Ordinary clear | `notes-prompt-03b-cleared.png` | `d31ed9bd7e9ce77d6ca1c2cb2fd1d052b75a96f2db398b969353127496dcc6c7` | `0fb1b94f36f66af1cf70998d68d859ed26c4cef01be8511c54ceaf4df517777e` | +| Exact restoration | `notes-prompt-04-restored.png` | `55663e6c14ce7f743f03c820c1560538cfa87aa74e26763c86b92c15c6291f11` | `a68c13b7ec031be40633d8cbcedefcd2cd433272c50fae3ec54fb8e5c598d604` | + +The receipt records actual fresh/restored Notes glyph bounds inside the real User +Prompt and editor viewport, raw-panel absence after clear, a new restored public +read, unchanged canonical history after released read, and preservation of saved +material and the unrelated F draft. The workflow result is terminal success; +no passing run was restarted. + +## Preserved attempts and remaining inspection gap + +| Attempt | Frozen QA | Result | Artifact / ZIP SHA-256 | +| --- | --- | --- | --- | +| [37709357998](https://github.com/MrScripty/Eidetic/actions/runs/37709357998) | `75f04f28c5c6256053eca6396550a209cb5d8fe2` / tree `2a3263b2fd316636414df6ef379052f27e07b385` | Hosted gates pass; original glyph check times out below editor edge. QA adds native text scrolling. | `11521497436`, 432439 bytes / `5577d26de6d449cf8e9dd9c4ac6342119e79a48c5aa6bcf4d70b6a4e1ed46084` | +| [37711020635](https://github.com/MrScripty/Eidetic/actions/runs/37711020635) | `795e1d5c4716c04904d20f5a053582b583db748d` / tree `dbfb8f1ce28ebd6cf93abbf9619efa44313852fd` | Three native states pass; empty-string helper selects without deleting. QA adds BackSpace for empty input. | `11521319325`, 1752027 bytes / `9fc49c262c00300cf27df08ddf1433bd0a1a1b22e250f82e57ff5aff28456789` | +| [37712308609](https://github.com/MrScripty/Eidetic/actions/runs/37712308609) | `edf5b9910375b5f1f08bb14f3491ba25d72d27f6` / tree `61653d61622b5ee4381cf29f5d0d41555c4fbf8e` | Clear succeeds; broad empty-field locator types into unsaved Bible Summary draft, then exact Notes commit check refuses. QA uses observed NOTES label at x296. | `11522328081`, 1731180 bytes / `6c3c7e96a72a7db76a4e45e8bbe4362944d41e4cfc5399b2c0269d6ce5b6cc7a` | + +All attempts preserve originals and decoded logs outside Git; no product change +was made to repair those qualifier defects. Executor transfer of the artifact +archive is refused by its network proxy with HTTP403, including a supported +explicit network-access request. The connector archive reference and verified +GitHub artifact metadata are available. No personal screenshot-inspection claim +is made. Parent owns independent visual inspection, durable Library delivery and +later PR/review/merge decisions. Pumas modality publication remains separate. diff --git a/docs/plans/agent-story-workflows/notes-prompt-preview.md b/docs/plans/agent-story-workflows/notes-prompt-preview.md new file mode 100644 index 00000000..1ca668b8 --- /dev/null +++ b/docs/plans/agent-story-workflows/notes-prompt-preview.md @@ -0,0 +1,56 @@ +# Exact committed Notes in the selected agent prompt preview + +Base: reviewed PR22 head `5a8670d95e44915426986faa76b5d1739fc0e036`. +Separate branch `fix/notes-prompt-preview-custody`; PR19/20/21/22 stay unmerged. +This completes a bounded part of the existing manual authoring Memory read / +Projection propagation criterion. Selected Notes already produce targeted +screenplay review with explicit acceptance; that feature remains intact. + +## Evidence and ownership + +`BeatEditor` passes selected canonical Notes into `contextRequestLifecycle.update`. +The lifecycle checks Notes only for emptiness, then deduplicates on node ID and +script-context revision. A same-node nonempty Notes edit advances neither key, so +the displayed raw AI prompt remains old even though selected Notes and screenplay +impact refresh. `getAiContext` already calls `ai_context_preview`, whose server +owner loads the canonical SQLite project and captures generation context. No +backend/model endpoint or new store is needed. On exact base, six new Notes-only +tests fail and seven maintained cases pass; the log stays outside source Git. + +## Decision and gates + +Extend the existing request owner's cache identity with exact committed Notes. +Do not trim/normalize nonempty Notes. A changed value clears the displayed old +context, loads canonical context and advances the existing request ID. Its +success/failure/finalizer cannot replace newer Notes/selection/revision requests. +Clearing Notes clears the identity; a restore requests fresh evidence. Retain +ordinary manual Refresh and unchanged-key deduplication. No saved story/script, +draft or proposal write is performed and no acceptance policy changes. + +Tests cover Unicode/whitespace-only edits, Notes edit/restore ABA, overlapping +Refresh and late failure/success ordering. Native qualification must exercise +ordinary Notes and raw-prompt controls, with a visibly labelled held real context +response, using unchanged application files. Seed model replies stay explicitly +synthetic; the context read itself requires no inference. Show Bible, timeline, +screenplay and exact fresh Notes in prompt evidence. Original PNGs and sanitized +logs remain artifacts outside Git; display derivatives use JPEG quality 85. + +## Alternatives and limits + +Waiting for a screenplay edit or requiring manual Refresh leaves a known Notes +change stale. A second context cache or a guessed Pumas endpoint duplicates +ownership. Reuse the public read and request-ID guards. Other metadata/context +membership, unobserved history-only changes, project switching and modality +publication remain separate; this is exact committed Notes propagation, not +real-model quality qualification. + +## Qualification closure + +Frozen product `c5fad5cbca6f1d3ba42bcdb66c833ac1d513dbf4` and separate QA +`11b60a92c6fed0040f34c3a153329b3f8c7af826` pass hosted run37713651568, +including exact Notes refresh, held older real return refusal, ordinary clear and +exact restoration. Core123/server542/UI557/96, driver50/policy5 and maintained +gates pass. See `notes-prompt-preview-qualification.md` for exact sources, five +PNG/JPEG85 hashes and preserved qualifier failures. Personal visual inspection +remains blocked by executor archive transfer HTTP403; source-only provenance +closure does not claim seeing those images. Parent handles inspection/delivery. diff --git a/docs/plans/agent-story-workflows/plan.md b/docs/plans/agent-story-workflows/plan.md index 4301fef6..f9772a86 100644 --- a/docs/plans/agent-story-workflows/plan.md +++ b/docs/plans/agent-story-workflows/plan.md @@ -1,6 +1,35 @@ # Agent-assisted story workflows **Status:** Active +**Separate known-empty arc Description implementation:** A public metadata edit +reproduction on template-tagged Scenes established missing downstream review. +`feat/arc-description-applicability` now captures owned empty-field applicability +beside consumed arc inputs in existing history/graph receipts. Filling identifies +precise Scene consumers; clearing withdraws that cause; stale/ABA admission, +manual text, locked spans and explicit acceptance remain guarded. Legacy unknown +reads are not backfilled. Native functional qualification passes run37726307798 (product cac013f / +QA1989909), core123/server566/UI562/96 and all maintained gates. Independent +source/visual review remains for the parent; synthetic replies qualify function, +not real-model quality. See `arc-description-applicability.md` and +`arc-description-qualification.md`; PR19–23/main remain unmerged. +**Ancestor Notes propagation independently accepted:** Generation now +records exact consumed nonempty ancestor Notes with owned field revisions and +precise graph dependency identities. Existing review shows original/current +ancestor evidence; pending generation, preview and acceptance reject stale/ABA +reads, and saved/manual/locked material stays intact until explicit acceptance. +The c2659fa failing reproduction is now an ordinary passing test. Locked +core123/server556, UI558/96 and strict Clippy pass. See +`ancestor-notes-review-gap.md` and `ancestor-notes-qualification.md`. Frozen product10a2b43 / QAc09b992 passes native run37721620953, core123/server556/UI558/96 and maintained gates. Root independently reviewed exact source and all five native images, verifying artifact/source hashes. Review applies to the bounded synthetic gate; real-model quality remains unqualified. The unrelated Act gains no new ancestor-Notes cause and retains pre-existing causes. +**Selected Notes prompt follow-up qualified:** Frozen product `c5fad5c` / QA +`11b60a9` passes native run37713651568: exact committed Notes refresh, older real +return refusal, clear and exact restoration, saved material and unrelated draft +preservation. Hosted core123/server542/UI557/96 and maintained gates pass. Five +source-bound PNG originals/JPEG85 derivatives are artifact11523381856. Personal +visual inspection remains blocked by executor transfer HTTP403; parent retains +inspection/durable delivery. See `notes-prompt-preview-qualification.md`. +PR22 remains a draft on reviewed Notes/report head `5a8670d`; PR19-22/main are +untouched. This uses the existing public read and request owner, with no new +model, embedding dependency or acceptance behavior. Pumas publication stays separate. **Separate timeline Notes successor:** PR21 `1bf7873` preserves exact placement, Bible and screenplay review flows. A source regression proves public authored Notes changed after generation has no review cause. The bounded successor reuses @@ -1104,3 +1133,57 @@ UI aggregate and independent review, then actual hosted native acceptance with clean blue Bible, dirty draft, screenplay and placement preserved. All model responses are labelled synthetic. Source and QA deltas/evidence remain separate; no PR/merge or real-model download belongs to this stage. + + +## M5 follow-on: explicit saved screenplay block removal + +Admitted from verified public PR25c5f3587 above PR24b17bfbc. The current writer +can append/edit saved blocks but cannot remove one through the application. +Expose explicit confirmation for one exact unlocked block; retain historical +text, timeline placement, all other blocks and manual drafts. Reuse the existing +soft-deletion/history and deleted-input impact review, with immutable retry +receipts that remain visible after canonical disappearance. Old downstream +previews must refuse; fresh targeted preview preserves saved text until explicit +acceptance. See `screenplay-removal-audit.md` for the pre-edit evidence/write set. +Native functional qualification passes run37787616709 on frozen product D025/E1f +with QA10dd3cd: both fresh isolated native scenarios verify exact replay, stale +refusal without writes and separate fresh acceptance. Final successful pixel +inspection and Library delivery require the parent's working transfer route. +See `screenplay-removal-qualification.md`; synthetic replies do not establish +real-model writing quality. + +### Canonical timeline clip titles (separate successor) + +- Verified gap and admitted scope: [timeline-title-memory-audit.md](timeline-title-memory-audit.md). +- Local milestone: ordinary title Save, exact old/new history, stale/ABA guards, + immutable unknown acknowledgement retry and session-owned draft preservation. +- Focused history tests pass 3; title draft/session/client tests pass 7; Svelte + check passes with zero errors/warnings. Consumed-title review and native + qualification remain pending. No title publication or Undo UI is claimed. + +Consumed-title implementation now uses existing generation/proposal/dependency +history for exact selected/ancestor/sibling/recap names. Targeted review preserves +manual text until explicit acceptance and refreshes title consumption afterward. +Focused checks cover baseline and owned clocks, Unicode, stale/ABA generation and +acceptance, deleted-source absence ABA, legacy unknown history and untouched +material. Independent source review and native title qualification remain pending; +PR26/removal source and evidence are unchanged. + +### Timeline Notes draft custody (separate admitted successor) + +After reviewed PR27 repair integration515b4b17, preserve block-type57b0/ee167. +The existing Notes writer and selected/ancestor propagation work, but its +component-local debounce discards pending text on panel destruction or a second +clip edit and owns no retry draft. The actual helper reproduction issues zero +commands for exact Notes disposed before500ms, while existing cancellation tests +pass. Begin independent `feat/timeline-notes-draft-custody`; see +[timeline-notes-draft-custody-audit.md](timeline-notes-draft-custody-audit.md). +Reuse current editor-session ownership, Notes commands and owned field history, +preserving exact dirty/queued/uncertain input and existing downstream acceptance. +The implementation now uses explicit Save Notes and per-session/per-clip custody; +unsaved input never auto-commits on disposal. Optional existing Notes receipts +protect the canonical writer and immutable acknowledgement retry. Local public +SQLite and rendered Chromium checks cover interruptions, two clips, lock-after- +lost-ack recovery, save/discard/reload and canonical conflict. See the audit for +exact proof boundaries and external source/evidence handoff. No new hosted job, +schema, embedding or project-switch recovery scope. diff --git a/docs/plans/agent-story-workflows/screenplay-removal-audit.md b/docs/plans/agent-story-workflows/screenplay-removal-audit.md new file mode 100644 index 00000000..3e38fe84 --- /dev/null +++ b/docs/plans/agent-story-workflows/screenplay-removal-audit.md @@ -0,0 +1,79 @@ +# Saved screenplay block removal: implementation admission + +## Verified base and non-overlap + +Product branch `feat/manual-screenplay-removal` starts at public PR25 +`c5f358710d5a27f776c82a394b28d46e186f9c01`, tree +`a27a93791de9adc3882a2cd2da58af3621bc9732`, directly above PR24 +`b17bfbc2506a67762e1cf0ae9dcfad81c933b049`. GitHub check runs for PR25 +confirm Linux, Windows, frontend and traceability passed (run37764315165). +Linux/Windows ran571server +123core; frontend562tests. Its CodeRabbit +conversation reports a full clean review of its three changed files. + +PR24 already fixes known-empty arc-description applicability, including name +changes while description is empty, a remaining empty tag, and deletion/absence +receipts. PR25 preserves the exact selected SQLite path. Neither repair is +repeated here. All previous49worktrees and original untracked evidence remain +preserved; this work uses a new isolated50th worktree. + +## Concrete application gap + +`ScriptBlockEditor.svelte` exposed Edit/Save and `ScriptBlockComposer.svelte` +appended user-authored blocks. `commandApi.ts`, native registrations and core +contracts had no manual block-removal command. Writers could remove prose only +by saving an empty block or replacing a whole segment through an agent patch. +Neither expresses deletion of one exact block. + +The script-generation plan already calls for constrained block/span patches, +including deletion of unlocked blocks. Existing `script_segment_replace.rs` +soft-deletes omitted blocks and spans; `script_impact_projection.rs` understands +deleted consumed blocks and retains historical input excerpts. The feature +exposes these existing owners to explicit manual authoring. It keeps the timeline +clip and segment, other blocks, history, Bible facts and graph canon intact. + +## Exact product write set reported before source edits + +- Core: `contracts/script_document.rs`, `contracts/mod.rs`, `contracts/README.md`. +- Server: `lib.rs`, `command_service.rs`, new `script_block_remove.rs`, + `script_block_remove_tests.rs`, `script_block_remove_impact_tests.rs`, `README.md`. +- Native: `commands/object_script_story.rs`, `lib.rs`, `commands/README.md`. +- UI library: `scriptTypes.ts`, `commandApi.ts`, `README.md`. +- UI stores: `scriptDocumentProjection.svelte.ts`, `scriptBlockEditSession.svelte.ts`, + `README.md`. +- UI editor: `scriptBlockEditDraft.svelte.ts`, `ScriptBlockEditor.svelte`, + `ScriptPanel.svelte`, new `ScriptBlockRemoval.svelte`, + `scriptBlockRemoval.svelte.test.ts`, `README.md`. +- Plans/evidence: parent `plan.md`, this audit, and a new qualification report. + +Native qualification belongs to a separate branch using the existing ten-file +manual-fact fixture boundary. No embedding/model dependency or credential change. + +## Acceptance gates + +Explicit confirmation captures exact saved block revision. Stale text/ABA and +locks refuse before a committed mutation; the writer recaptures rows and retained +membership. Removal is a UserEdit with a deleted-block history revision preserving +exact original text, deleted spans, and sparse segment-membership delta. Retry +uses the original command ID and replay precedes missing-block/version checks. + +The existing per-block authoring session owns removal confirmation and any lost +acknowledgement receipt. An editing draft cannot enter removal. Unknown failure +keeps an immutable receipt and visible retry even if a ScriptChanged refresh has +already removed the block. Project-session retirement prevents retargeting. + +Affected saved scene material remains unchanged until fresh targeted preview and +separate explicit acceptance. Old previews refuse after source removal. Fresh +current memory excludes removed prose while impact causes retain consumed text. +Unrelated material and timeline placement remain intact. Synthetic responses +qualify mechanics only, never real-model writing quality. + +## Client-runtime qualification repair + +The initial product milestone34260ac passed SSR/full static checks, but a direct +client-compiled probe demonstrated `state_unsafe_mutation` when a block component +first registered its reactive session owner inside `$derived`. Registration now +runs through Svelte `untrack`; the reactive map continues to drive orphan removal +receipts. A regression compiles the actual session module for the client and +creates/reuses its owner inside a real client derived reaction. The initial QA +run37778991927 is retained as superseded evidence; it cannot qualify the repaired +source. Source/QA bindings are frozen again after the repair checks. diff --git a/docs/plans/agent-story-workflows/screenplay-removal-qualification.md b/docs/plans/agent-story-workflows/screenplay-removal-qualification.md new file mode 100644 index 00000000..0638e852 --- /dev/null +++ b/docs/plans/agent-story-workflows/screenplay-removal-qualification.md @@ -0,0 +1,125 @@ +# Saved screenplay block removal qualification + +**Native functional qualification passed. Final independent pixel inspection of +the successful captures and Library delivery require the parent's authenticated +transfer route.** This executor has not personally inspected the new PNG pixels. + +## Exact source and scope + +- Reviewed application: `d025abc67cbd82413c2ec896fb6ab01b1ea13402`, tree + `9d2a598cde7b3efd9f6e522ae5c7c362659a44ce`. +- Normal feature-parent merge: `e1f56157fd9ec0394f9e05ee758cb9b1fabf8942`, + identical application tree; parent `29fb5b63a9b0c1a2dd5b51c41654b8ac5ca5a61b`. +- Qualification: `10dd3cdd9acc18d6297fce3ff6f30533bde3d9af`, tree + `2fd75c8c717cabf361c4955a8bf2624900b14cab`. Only the admitted ten QA files + differ from D025. Production remains unchanged by the QA repairs. +- Hosted run [37787616709](https://github.com/MrScripty/Eidetic/actions/runs/37787616709), + job `113346320285`, succeeds in both separate isolated native scenarios. +- Actual native binary SHA256: + `36c718a9de8c13a52760475ab8cb2382025e6b71b441a2b547ab9c05b57da50a`. + +The feature removes one exact unlocked saved block after explicit confirmation. +It reuses UserEdit history, deleted block/span revisions, sparse membership, +existing graph dependencies and targeted review. It retains the timeline clip +and segment, other blocks, Bible canon and unrelated drafts. An immutable unknown +acknowledgement receipt stays reachable after canonical disappearance and +Graph/Script navigation; exact retry returns AlreadyRecorded. Preserved history +does not imply an Undo/Restore interface. + +Root independently reviewed exact D025 source with no blocking defect. Its +nonblocking optional append-race/child-plan tests were not used to expand this +slice. No project-switch feature, embedding dependency or semantic extraction +was added. + +## Executed gates + +Local locked all-target tests pass123 core /575 server; strict all-target, +all-feature Clippy passes. UI passes569 tests in97 files, zero check errors or +warnings, lint, formatting and build. The final QA driver suite passes88 tests; +normal commit hooks also pass UI lint/typecheck. Hosted qualification reruns +123 core /575 server with zero failures or ignored tests and builds the actual +Tauri/native renderer against the existing audited no-download dependency setup. +No local ONNX403 workaround, new SDK/model acquisition or real inference occurs. + +Each native scenario uses a fresh isolated project. Public services and four +labelled synthetic HTTP/SSE generation/recap calls establish genuine saved A/B +consumption. Ordinary accessibility/X11 actions then: + +1. Save exact manual B and A text, retain F's unrelated unsaved draft, and display + the Bible, timeline and screenplay together. +2. Select A's changed-input cause in B, create a pending targeted preview, open + the exact-text removal confirmation, and cancel without any canonical write. +3. Confirm removal through the actual public command. A labelled QA wrapper + discards its acknowledgement only after the genuine backend commit. +4. Retain the same visible receipt after Graph/Script navigation and canonical + disappearance; retry exactly the same command/payload as AlreadyRecorded. + Canonical and history tables remain unchanged by replay. +5. Attempt the old pending acceptance. The visible B notification reads + `screenplay proposal is stale; request a fresh preview`, role `notification`, + bounds `[739,313,396,16]` in both cases. Full canonical equality proves refusal + records no write. Historical deletion preserves exact authored source text. +6. Reject the old preview, select the actual deleted-block cause and request a + fresh targeted update. Its historical consumed excerpt remains available; + current inputs exclude the removed block. Manual B remains saved until a + separate explicit Accept update. +7. Accept B alone, preserving F's saved block and draft, Bible fields and timeline + placement, and refreshing B's lineage so it no longer consumes removed A. + +Both scenarios reach the final complete checkpoint. Twelve synthetic HTTP/SSE +calls total are accepted. Synthetic replies qualify mechanics; real-model +writing quality remains unqualified. No DOM/IPC injection or direct database +writes substitute for these native GUI actions. + +## Preserved failures and driver repairs + +- Run37778991927 exposed a client `state_unsafe_mutation`; D025 repairs owner + registration through untrack and adds a real client-compiled regression. +- Run37780415328 exposed the QA SQL read's nonexistent binding column; D1f uses + the actual production binding table with a schema-backed read-only regression. +- Run37782429671 timed out on a whole-app first-match error lookup. Root inspected + the exact failure ZIP and all four originals/eight image hashes; the stale + notification was visibly present in A and B. A hidden-first/visible-later + regression fails the old selector. QA8d scopes lookup to B's visible review. +- Run37785443840 executes that visible refusal and its canonical no-write check, + then exposes a second QA defect: a new continuity cause precedes the removed + block, but the driver indexed a filtered cause list. Regressions fail0 versus + the actual positions1/2. QA10dd retains complete canonical cause order for + native menu selection and records refusal text/role/bounds. The provider's + strict deleted-cause validation and every application assertion remain intact. + +These runs and their original artifacts are preserved; failed evidence is not +relabelled as success. + +## Exact capture deliverable + +[Artifact11555048710](https://github.com/MrScripty/Eidetic/actions/runs/37787616709/artifacts/11555048710) +contains10 PNG originals,10 JPEG85 derivatives, two full capture-evidence JSON +receipts and sanitized native/host logs. The second case is under +`screenplay-removal-repeat/`. ZIP5036753bytes, SHA256 +`06c6a7e7fa4a1e340b0ad654c5bae944db57288c2cb02880e00c4a94700181fd`. +Reusable file `file_000000000fe0820d95b1dfb392bb9f75`; hosted retention expires +2026-10-11T14:00:21Z. Full original/derivative hashes are in the capture receipts. + +The executor's artifact byte route returns HTTP403. The required current Library +upload helper also failed before tools/list with a hosted-app network error; +no upload/finalize or Library file/version ID was confirmed. Local source/history +archives and evidence remain preserved. Parent must inspect the successful +capture pixels and use its working authenticated route for durable delivery. +Draft PR publication remains pending that final qualification gate. + +## Next concrete authoring slice + +Ordinary timeline clip-title editing is missing: BeatEditorHeader renders a +read-only title and typed timeline commands have no rename. Actual prompts use +selected, ancestor and sibling titles, while ScriptGenerationTarget and existing +generation/review inputs contain no consumed timeline-title values. + +A private public-command probe on an isolated backup submitted exact +` Station departure — 雨. ` through SetObjectFieldCommand(TimelineNode,B,name). +The sparse field recorded, but canonical B remained SCENE B, the actual prompt +remained byte-identical and saved screenplay was unchanged. Thus the generic +history command is not an existing canonical rename route. This reproduction +uses zero model calls and does not claim native title editing. No title feature +has been implemented yet. Its separate slice must pair explicit authoring with +exact consumed-title provenance and existing targeted preview/acceptance, +preserving drafts, placement and stale guards. diff --git a/docs/plans/agent-story-workflows/timeline-notes-draft-custody-audit.md b/docs/plans/agent-story-workflows/timeline-notes-draft-custody-audit.md new file mode 100644 index 00000000..febff269 --- /dev/null +++ b/docs/plans/agent-story-workflows/timeline-notes-draft-custody-audit.md @@ -0,0 +1,108 @@ +# Timeline Notes draft custody: admitted authoring successor + +Starts from integrated PR27 head `515b4b17b9461c49aa2ea208ffba87b817b77ce4` +(tree `eac8c47b7a2d229a3e254ee2d1d76e92ad51c5ab`) on separate +`feat/timeline-notes-draft-custody`. PR27 and the independently preserved +block-type product57b0 / QAee167 remain outside this branch's write set. + +## Concrete evidence before implementation + +The roadmap's manual-authoring acceptance includes repeated/interrupted writing, +unchanged human drafts and propagation into inspectable agent memory. Selected +and ancestor Notes already propagate through existing history, generation source +receipts and targeted screenplay review. Adding another Notes command, semantic +store or preview/accept flow would duplicate working behavior. + +`BeatEditor.svelte` creates one component-local 500ms +`createDebouncedNodeNotesSave` for every selected clip. `handleNotesInput` passes +the exact textarea value into `schedule`; `onDestroy` calls `dispose`. +`debouncedNodeNotesSave.ts` retains only a timer, cancels it before every new +schedule, cancels it on disposal, and invokes `void save(...)` without owning a +failed/uncertain submission or retained draft. `BeatNotesPanel.svelte` renders the +committed projection's Notes; unlike title editing, no session Notes draft owner +can restore cancelled input when Script returns. The current Notes command also +lacks the title/range commands' expected author read. + +An outside-Git reproduction executes the actual TypeScript helper with a virtual +clock and checks the editor's actual schedule/disposal call sites. With exact +input ` Mara reveals the witness — 雨.\n\n `, disposal before500ms issues zero +canonical commands and exposes no retained-draft read. Scheduling input for a +second clip before500ms issues only the second clip's command. Existing three +debounce tests pass because they explicitly specify those cancellation semantics. +This is source/helper evidence, not a native UI or SQLite integration claim. Its +receipt is retained in `timeline-notes-draft-custody-evidence/notes-custody-baseline.json` +outside Git. No model/provider execution occurred. + +The missing feature is custody of the authored Notes intent during ordinary +same-project selection/panel changes, failed saves and uncertain acknowledgement. +Without a canonical save, these intended story changes never reach graph-backed +generation/agent reads or the existing downstream review machinery. + +## Bounded implementation plan + +- Own exact Notes text, original read and save status in the existing editor + session, separately per clip; reuse the title/edit session lifetime patterns. + Retain the current Notes command with explicit Save Notes. The latest owner + instruction prohibits silently saving unaccepted text and supersedes the audit + checkpoint's proposed autosave cadence. Leaving a panel must neither save nor + discard an intent nor retarget it to the newly selected clip. +- Carry an optional exact author read using existing owned Notes field history; + compare it inside the current writer transaction. Keep legacy wire behavior and + command replay. Notes edit/restore ABA must refuse a stale read without writes. +- Preserve dirty Notes against canonical refresh. Show current committed conflict + evidence and explicit discard/reload; do not silently rebase authored text. + Unknown completion retains immutable command ID/text/read for an explicit retry. + Keep later queued authored text separately from the submitted immutable payload. +- Acknowledged writes continue through canonical projection refresh, source-memory + invalidation and existing stale-result guards. Agent/prompt reads use saved + Notes only. Existing downstream proposals still require separate acceptance + before replacing any saved screenplay; do not add an alternative review flow. +- Qualify actual client session/proxy behavior and public SQLite command/history + behavior locally, including two clips, panel remount, concurrency, ABA, failure, + uncertain retries and unaffected drafts/screenplay. Independent review precedes + publication of implemented product behavior. + +## Current milestone and proof limits + +Implemented on the separate Notes branch: a session-owned per-clip draft replaces +BeatEditor's component-local debounce. Save Notes captures an immutable command +ID, exact text and existing `TimelineNotesInput`. The existing writer compares the +original author read in its transaction, excluding its tentative Notes event; +stale/ABA, wrong-node, locked and unchanged guarded edits roll back all rows. +Legacy clients omit the guard and keep their existing wire behavior. + +The existing command response adds optional `notes_read`: the original historical +receipt of that exact command, including a replay after newer Notes or a late +lock. Missing/uncertain receipts retain retry custody. Only a proven precommit +refusal releases it. Failed postcommit publication/refresh cannot be labelled a +precommit refusal; independent projection refresh does not invalidate a received +owned acknowledgement. Later queued text remains unsaved. Agent reads and raw +prompt preview continue to use committed Notes; downstream screenplay replacement +still uses the existing explicit acceptance flow. + +Local qualification passes 123 core and 597 server tests, including the public +SQLite guarded-write/replay/save-reopen path and a guarded Notes edit that updates +existing impact history while preserving all saved screenplay material. Nineteen +focused UI tests cover custody, concurrency, immutable retries, failed reads, +rendered controls and the late-lock recovery case. The full UI suite and static +checks are recorded in the external evidence logs at publication. + +A local Chromium run uses real AppShell, Bible, BeatEditor, ScriptPanel and timeline +components through a labelled IPC test adapter to public Rust/SQLite services. +Seven screenshots and DOM receipts show exact unsaved input, panel remount, +uncertain acknowledgement after a real commit, retry despite a late lock, +acknowledged save, explicit B save, canonical conflict and session reopen. +The entire saved screenplay payload is identical before/after; the projection +version is allowed to advance with Notes history. No runtime exceptions occur. +The fixtures contain manually authored screenplay; downstream generated-consumer +impact is qualified by the separate SQLite test, not claimed from those pixels. +The adapter and transformed module bytes are retained outside Git for inspection. + +Unsaved custody is transient within an open project session. Reset/reopen expires +unsaved intents and reads committed Notes; project-switch/hard-reload recovery is +still deferred. No new schema, durable draft store, dependency, embedding or model +call is introduced. This is instrumented local browser/public-service evidence, +not native runtime, uninstrumented release, new hosted qualification or real-model +quality. Native renderer status is unavailable in this browser adapter. No hosted +jobs were started and main merge gates remain unchanged. Independent acceptance, +exact source IDs and evidence hashes are recorded in the publication handoff. diff --git a/docs/plans/agent-story-workflows/timeline-title-memory-audit.md b/docs/plans/agent-story-workflows/timeline-title-memory-audit.md new file mode 100644 index 00000000..101c671f --- /dev/null +++ b/docs/plans/agent-story-workflows/timeline-title-memory-audit.md @@ -0,0 +1,56 @@ +# Timeline title authoring and memory admission + +Separate branch feat/timeline-title-memory starts at reviewed removal report +8695363c4f66420a5e5a1cd8dd36bf47bf7207ed; PR26 and its evidence remain frozen. + +Verified gap: BeatEditorHeader displays node.name without an editing control; +typed timeline commands lack a canonical rename. The preserved isolated public +SetObjectField TimelineNode/name probe recorded sparse history but left nodes.name, +actual preview prompt and saved screenplay unchanged. Prompt formatting actually +supplies selected/ancestor/sibling names and preceding recap node names. Generation +and targeted review receipts do not bind these consumed names. + +Admitted milestone 1: core timeline command/selected-editor contracts, command +service/history, timeline node field-clock reads, selected projection, Tauri command +registration, UI command types/transport/projection, a transient title draft owned +by the existing editor session, ordinary title control in BeatEditor, focused +history/service/session/client tests, adjacent README/plan documentation only. + +Admitted milestone 2: an optional consumed-title receipt in existing generation +and proposal contracts; recap source identity; same-snapshot capture before provider +I/O; writer admission; existing graph TimelineNode dependencies; precise impact, +original/current/owned-absence preview evidence and acceptance recapture; targeted +review prompt/UI; associated source tests. Existing explicit preview/accept controls +remain the only replacement authority. Legacy absent receipts stay unknown. Known +baseline titles retain exact values and an explicitly absent owned field clock; +graph read stamps are not represented as fabricated authoring revisions. + +No new schema, embedding/model dependency, parallel durable state, project-switch +feature or claimed Undo UI. Name deltas preserve old/new exact text in existing +UserEdit history. The writer guard checks canonical title and owned name clock, +including ABA; replay precedes stale checks. A pending title intent survives +selection/panel navigation; unknown acknowledgement retries an immutable command. +A title change invalidates downstream prompt context without editing screenplay, +Notes, Bible fields, placement or unrelated drafts. + +Qualification uses existing locked/offline setup and hosted native application. +Synthetic replies must be labelled. No local ONNX403 bypass, external reviewer +request, main merge/release, screenshot/archive Git tracking or Library success +without confirmed upload. Independent review and affected full gates precede +publication; parent owns PRs/reviews and durable delivery. + +PR27 review successor on separate fix/timeline-title-prompt-receipts: the actual +canonical screenplay attachment removes legacy recaps before prompt formatting. +Capture title receipts after that same transformation inside the existing read +transaction. Recap titles supplied only to a raw legacy request are not consumed +by canonical generation; the raw-recap unit test is not production qualification. +Actual service regressions preserve exact canonical source text/receipts, exclude +the removed recap title, admit generation after its rename and leave saved output +unflagged after a subsequent rename. Identical titles refuse after the expected +read check without advancing history/name clocks; the client avoids sending such +commands while immutable uncertain retries remain authoritative. + +Owner paused new hosted CI spending. This successor uses local regression/full +checks and independent source review; original D20/F573 native screenshots do not +qualify its changed source. Existing main merge gates remain unchanged. No real +model execution or fresh successor native screenshots are claimed. diff --git a/src-tauri/src/commands/README.md b/src-tauri/src/commands/README.md index fe034fb4..a730b10f 100644 --- a/src-tauri/src/commands/README.md +++ b/src-tauri/src/commands/README.md @@ -100,3 +100,9 @@ so a deleted source need not be projected. Fresh writes still return source node detail. The adapter forwards this optional projection without inventing one. The explicit `command_script_fact_proposal_analyze` command delegates one saved manual edit and one actually consumed baseline text fact to the canonical capture/provider/proposal service. It returns pending proposals only; existing explicit propagation acceptance is the sole fact write. + + +`command_script_block_remove` forwards exact manual screenplay removal to the backend project-session owner and existing document projection response. + +`command_timeline_node_name` saves a canonical clip title using the required exact +title read receipt and existing timeline command publication. diff --git a/src-tauri/src/commands/object_script_story.rs b/src-tauri/src/commands/object_script_story.rs index 1a717ace..6aba0236 100644 --- a/src-tauri/src/commands/object_script_story.rs +++ b/src-tauri/src/commands/object_script_story.rs @@ -41,6 +41,17 @@ pub async fn command_script_block_create( .map_err(CommandError::from) } +#[tauri::command] +pub async fn command_script_block_remove( + app: tauri::AppHandle, + command: CommandEnvelope, +) -> Result { + let state = app.state::().inner().clone(); + command_service::remove_script_block(&state, command) + .await + .map_err(CommandError::from) +} + #[tauri::command] pub async fn command_script_lock( app: tauri::AppHandle, diff --git a/src-tauri/src/commands/timeline.rs b/src-tauri/src/commands/timeline.rs index 1ada303b..31a3935e 100644 --- a/src-tauri/src/commands/timeline.rs +++ b/src-tauri/src/commands/timeline.rs @@ -1,6 +1,7 @@ use eidetic_core::contracts::{ CommandEnvelope, DeleteTimelineNodeCommand, DeleteTimelineRelationshipCommand, - SetTimelineNodeLockCommand, SetTimelineNodeNotesCommand, SetTimelineNodeRangeCommand, + SetTimelineNodeLockCommand, SetTimelineNodeNameCommand, SetTimelineNodeNotesCommand, + SetTimelineNodeRangeCommand, }; use eidetic_server::command_service; use eidetic_server::projection_service; @@ -36,6 +37,17 @@ pub async fn command_timeline_create_child_from_parent( .map_err(CommandError::from) } +#[tauri::command] +pub async fn command_timeline_node_name( + app: tauri::AppHandle, + command: CommandEnvelope, +) -> Result { + let state = app.state::().inner().clone(); + command_service::set_timeline_node_name(&state, command) + .await + .map_err(CommandError::from) +} + #[tauri::command] pub async fn command_timeline_node_range( app: tauri::AppHandle, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 890187c0..8f3d98b6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -123,6 +123,7 @@ pub fn run() { commands::object_script_story::command_script_block, commands::object_script_story::command_script_block_edit, commands::object_script_story::command_script_block_create, + commands::object_script_story::command_script_block_remove, commands::object_script_story::command_script_lock, commands::object_script_story::command_story_create, commands::object_script_story::command_story_update, @@ -154,6 +155,7 @@ pub fn run() { commands::semantic::command_propagation_proposal_accept, commands::timeline::command_timeline_create_node, commands::timeline::command_timeline_create_child_from_parent, + commands::timeline::command_timeline_node_name, commands::timeline::command_timeline_node_range, commands::timeline::command_timeline_node_lock, commands::timeline::command_timeline_node_notes, diff --git a/ui/src/lib/README.md b/ui/src/lib/README.md index 17bc01ab..e1dafe4a 100644 --- a/ui/src/lib/README.md +++ b/ui/src/lib/README.md @@ -184,3 +184,9 @@ evidence. The editor displays exact changes and optional full revision details; Decision: keep Preview update and Accept update as the existing explicit review boundary, preserving unrelated author drafts and backend source/version custody. + + +`removeScriptBlock` carries the captured block revision and stable command ID through native transport. It shares the screenplay projection response with create/edit commands. + +Timeline title transport carries exact text and its owned field read to the +canonical native rename command; it does not use generic sparse object fields. diff --git a/ui/src/lib/commandApi.ts b/ui/src/lib/commandApi.ts index 69bcb20b..a503c844 100644 --- a/ui/src/lib/commandApi.ts +++ b/ui/src/lib/commandApi.ts @@ -46,6 +46,7 @@ import type { CreateScriptBlockCommand, SetScriptBlockCommand, EditScriptBlockCommand, + RemoveScriptBlockCommand, SetScriptLockCommand, } from './scriptTypes.js'; import type { @@ -74,6 +75,7 @@ export { setTimelinePlayhead, setTimelineNodeLock, setTimelineNodeNotes, + setTimelineNodeName, setTimelineNodeRange, splitTimelineNode, } from './timelineCommandApi.js'; @@ -422,6 +424,14 @@ export function setScriptLock( return invokeDesktop('command_script_lock', { command }); } +export function removeScriptBlock( + payload: RemoveScriptBlockCommand, + commandId = createCommandId(), +): Promise { + const command: CommandEnvelope = { id: commandId, payload }; + return invokeDesktop('command_script_block_remove', { command }); +} + export function editScriptBlock( payload: EditScriptBlockCommand, commandId = createCommandId(), diff --git a/ui/src/lib/components/editor/BeatEditor.svelte b/ui/src/lib/components/editor/BeatEditor.svelte index a6dc36b6..d53174c8 100644 --- a/ui/src/lib/components/editor/BeatEditor.svelte +++ b/ui/src/lib/components/editor/BeatEditor.svelte @@ -19,7 +19,6 @@ import { applyTimelineChildrenCommand, applyTimelineNodeLockCommand, - applyTimelineNodeNotesCommand, } from '$lib/stores/timelineRenderProjection.svelte.js'; import { refreshSelectedNodeEditorProjection, @@ -28,29 +27,17 @@ import BeatChildContext from './BeatChildContext.svelte'; import BeatEditorHeader from './BeatEditorHeader.svelte'; import TimelinePlacementEditor from './TimelinePlacementEditor.svelte'; + import TimelineTitleEditor from './TimelineTitleEditor.svelte'; import BeatNotesPanel from './BeatNotesPanel.svelte'; import BeatPlanningActions from './BeatPlanningActions.svelte'; import ChildPlanReview from './ChildPlanReview.svelte'; import { createChildPlanReview } from './childPlanReview.svelte.js'; import { beatContentStatusLabel } from './beatEditorStatus.js'; - import { createDebouncedNodeNotesSave } from './debouncedNodeNotesSave.js'; import { createContextRequestLifecycle } from './contextRequestLifecycle.js'; import './beatEditor.css'; import { createSelectedTimelineChild } from './createSelectedTimelineChild.js'; import { scriptDocumentProjectionState } from '$lib/stores/scriptDocumentProjection.svelte.js'; - const debouncedNotesSave = createDebouncedNodeNotesSave({ - delayMs: 500, - async save(nodeId, notes) { - await applyTimelineNodeNotesCommand({ - node_id: nodeId, - notes, - }); - if (editorState.selectedNodeId === nodeId) { - await refreshSelectedProjection(); - } - }, - }); const childPlanReview = createChildPlanReview({ owner: () => ({ nodeId: editorState.selectedNodeId, session: getEditorSessionGeneration() }), mounted: () => editorMounted, @@ -256,16 +243,8 @@ if (editorState.selectedNodeId) loadContext(editorState.selectedNodeId); } - function handleNotesInput(event: Event) { - const nodeId = editorState.selectedNodeId; - if (!nodeId) return; - const value = (event.target as HTMLTextAreaElement).value; - debouncedNotesSave.schedule(nodeId, value); - } - onDestroy(() => { editorMounted = false; - debouncedNotesSave.dispose(); contextRequests.invalidate(); }); @@ -352,6 +331,7 @@ /> {#if selectedProjectionNode} + {/if} @@ -401,7 +381,7 @@ generationError={editorState.generationError} {nodeContext} {contextLoading} - onnotesinput={handleNotesInput} + editorNode={selectedProjection!.node!} onrefreshcontext={refreshContext} /> {:else} diff --git a/ui/src/lib/components/editor/BeatNotesPanel.svelte b/ui/src/lib/components/editor/BeatNotesPanel.svelte index daab2dda..2ee20d1d 100644 --- a/ui/src/lib/components/editor/BeatNotesPanel.svelte +++ b/ui/src/lib/components/editor/BeatNotesPanel.svelte @@ -1,5 +1,7 @@
- - + {#if isGenerating}