From c91465515742bb715a35aeba6de92481b74268d0 Mon Sep 17 00:00:00 2001 From: MrScripty Date: Thu, 8 Oct 2026 16:52:23 +0000 Subject: [PATCH 1/2] docs(timeline): admit Notes draft custody successor [skip ci] --- docs/plans/agent-story-workflows/plan.md | 14 ++++ .../timeline-notes-draft-custody-audit.md | 71 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 docs/plans/agent-story-workflows/timeline-notes-draft-custody-audit.md diff --git a/docs/plans/agent-story-workflows/plan.md b/docs/plans/agent-story-workflows/plan.md index 3896f639..79221d91 100644 --- a/docs/plans/agent-story-workflows/plan.md +++ b/docs/plans/agent-story-workflows/plan.md @@ -1168,3 +1168,17 @@ Focused checks cover baseline and owned clocks, Unicode, stale/ABA generation an 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. +This checkpoint admits the feature; runtime implementation and qualification are +pending. No new hosted job, schema, embedding or project-switch scope. 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..7eeecf33 --- /dev/null +++ b/docs/plans/agent-story-workflows/timeline-notes-draft-custody-audit.md @@ -0,0 +1,71 @@ +# 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 and autosave cadence. Leaving a panel must + neither 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 + +This checkpoint begins the separate feature with inspected source, a reproducible +missing-custody receipt and an admitted plan. Runtime implementation remains +pending; no draft-preservation fix, writer guard, screenshot or native acceptance +is claimed. The owner paused new hosted CI spending. Continue local qualification +without changing required main merge gates. No new durable store/schema, model or +embedding dependency, project-switch recovery or real-model quality claim belongs +to this slice. From c703ebb7a0016554b702124a794a4654968b42cd Mon Sep 17 00:00:00 2001 From: MrScripty Date: Thu, 8 Oct 2026 17:27:05 +0000 Subject: [PATCH 2/2] feat(timeline): retain explicit Notes drafts and guarded save receipts [skip ci] --- crates/core/src/contracts/README.md | 11 + .../src/contracts/selected_node_editor.rs | 3 + crates/core/src/contracts/timeline_command.rs | 2 + .../examples/authoring_capture_fixture.rs | 1 + .../examples/bible_fact_capture_fixture.rs | 1 + .../examples/scene_order_capture_fixture.rs | 1 + .../server/src/ai_generation_runtime_tests.rs | 1 + .../src/canonical_generation_service_tests.rs | 2 + .../src/child_plan_memory_service_tests.rs | 2 + crates/server/src/command_service_timeline.rs | 27 +- crates/server/src/projection_service.rs | 3 + .../src/script_generation_target_tests.rs | 2 + .../src/state_autosave_custody_tests.rs | 1 + .../src/timeline_command_guard_tests.rs | 1 + crates/server/src/timeline_command_history.rs | 23 ++ crates/server/src/timeline_command_tests.rs | 2 + .../src/timeline_notes_draft_command_tests.rs | 262 ++++++++++++++++++ crates/server/src/timeline_notes_lineage.rs | 46 ++- .../src/timeline_notes_lineage_tests.rs | 33 +++ .../src/timeline_postcommit_custody_tests.rs | 11 + .../src/timeline_title_command_tests.rs | 1 + docs/plans/agent-story-workflows/plan.md | 9 +- .../timeline-notes-draft-custody-audit.md | 55 +++- .../lib/components/editor/BeatEditor.svelte | 24 +- .../components/editor/BeatNotesPanel.svelte | 16 +- .../editor/TimelineNotesEditor.ssr.test.ts | 60 ++++ .../editor/TimelineNotesEditor.svelte | 103 +++++++ .../editor/timelineNotesDraft.svelte.test.ts | 190 +++++++++++++ .../editor/timelineNotesDraft.svelte.ts | 117 ++++++++ ui/src/lib/selectedNodeEditorTypes.ts | 2 + ui/src/lib/stores/README.md | 1 + .../timelineNotesSession.svelte.test.ts | 108 ++++++++ .../lib/stores/timelineNotesSession.svelte.ts | 62 +++++ ui/src/lib/timelineCommandTypes.ts | 3 + 34 files changed, 1138 insertions(+), 48 deletions(-) create mode 100644 crates/server/src/timeline_notes_draft_command_tests.rs create mode 100644 ui/src/lib/components/editor/TimelineNotesEditor.ssr.test.ts create mode 100644 ui/src/lib/components/editor/TimelineNotesEditor.svelte create mode 100644 ui/src/lib/components/editor/timelineNotesDraft.svelte.test.ts create mode 100644 ui/src/lib/components/editor/timelineNotesDraft.svelte.ts create mode 100644 ui/src/lib/stores/timelineNotesSession.svelte.test.ts create mode 100644 ui/src/lib/stores/timelineNotesSession.svelte.ts diff --git a/crates/core/src/contracts/README.md b/crates/core/src/contracts/README.md index 947bd8a4..067b08ea 100644 --- a/crates/core/src/contracts/README.md +++ b/crates/core/src/contracts/README.md @@ -183,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 diff --git a/crates/core/src/contracts/selected_node_editor.rs b/crates/core/src/contracts/selected_node_editor.rs index 88b00780..6760fce6 100644 --- a/crates/core/src/contracts/selected_node_editor.rs +++ b/crates/core/src/contracts/selected_node_editor.rs @@ -43,6 +43,8 @@ pub struct SelectedNodeEditorNode { 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)] @@ -143,6 +145,7 @@ impl From<&StoryNode> for SelectedNodeEditorNode { 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 24e5bbc3..9a2bc344 100644 --- a/crates/core/src/contracts/timeline_command.rs +++ b/crates/core/src/contracts/timeline_command.rs @@ -87,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 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_timeline.rs b/crates/server/src/command_service_timeline.rs index bda527ee..95d3d670 100644 --- a/crates/server/src/command_service_timeline.rs +++ b/crates/server/src/command_service_timeline.rs @@ -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, }) }) @@ -152,6 +155,7 @@ pub async fn set_timeline_node_name( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + notes_read: None, projection, }) }) @@ -205,6 +209,7 @@ pub async fn set_timeline_node_range( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + notes_read: None, projection, }) }) @@ -245,6 +250,7 @@ pub async fn set_timeline_node_lock( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + notes_read: None, projection, }) }) @@ -282,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, }) }) @@ -344,6 +364,7 @@ pub async fn delete_timeline_node( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + notes_read: None, projection, }) }) @@ -394,6 +415,7 @@ pub async fn delete_timeline_relationship( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + notes_read: None, projection, }) }) @@ -442,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, }) }) @@ -485,6 +508,7 @@ pub async fn apply_timeline_children( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + notes_read: None, projection, }) }) @@ -584,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/projection_service.rs b/crates/server/src/projection_service.rs index 60b554e2..c29e27a5 100644 --- a/crates/server/src/projection_service.rs +++ b/crates/server/src/projection_service.rs @@ -383,6 +383,9 @@ 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) diff --git a/crates/server/src/script_generation_target_tests.rs b/crates/server/src/script_generation_target_tests.rs index f402156d..1b9ac1fd 100644 --- a/crates/server/src/script_generation_target_tests.rs +++ b/crates/server/src/script_generation_target_tests.rs @@ -83,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(), }), @@ -212,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/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/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 2b5c0b29..dbc5738d 100644 --- a/crates/server/src/timeline_command_history.rs +++ b/crates/server/src/timeline_command_history.rs @@ -286,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) }, )?) @@ -618,3 +637,7 @@ 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_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 b8504ab4..24902aa2 100644 --- a/crates/server/src/timeline_notes_lineage.rs +++ b/crates/server/src/timeline_notes_lineage.rs @@ -15,15 +15,25 @@ 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| { @@ -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) diff --git a/crates/server/src/timeline_notes_lineage_tests.rs b/crates/server/src/timeline_notes_lineage_tests.rs index 72383296..a63854f7 100644 --- a/crates/server/src/timeline_notes_lineage_tests.rs +++ b/crates/server/src/timeline_notes_lineage_tests.rs @@ -15,6 +15,7 @@ pub(crate) fn notes(conn: &mut Connection, project: &mut Project, node: NodeId, conn, project, &CommandEnvelope::new(SetTimelineNodeNotesCommand { + expected: None, node_id: node, notes: text.into(), }), @@ -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 7521b2c7..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(), }); @@ -488,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(), }); @@ -526,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(), }), @@ -578,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(), }), @@ -803,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(), }); @@ -893,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}"), }), @@ -975,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_title_command_tests.rs b/crates/server/src/timeline_title_command_tests.rs index 3930049a..4ccae0f6 100644 --- a/crates/server/src/timeline_title_command_tests.rs +++ b/crates/server/src/timeline_title_command_tests.rs @@ -176,6 +176,7 @@ 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(), }); diff --git a/docs/plans/agent-story-workflows/plan.md b/docs/plans/agent-story-workflows/plan.md index 79221d91..f9772a86 100644 --- a/docs/plans/agent-story-workflows/plan.md +++ b/docs/plans/agent-story-workflows/plan.md @@ -1180,5 +1180,10 @@ 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. -This checkpoint admits the feature; runtime implementation and qualification are -pending. No new hosted job, schema, embedding or project-switch scope. +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/timeline-notes-draft-custody-audit.md b/docs/plans/agent-story-workflows/timeline-notes-draft-custody-audit.md index 7eeecf33..febff269 100644 --- a/docs/plans/agent-story-workflows/timeline-notes-draft-custody-audit.md +++ b/docs/plans/agent-story-workflows/timeline-notes-draft-custody-audit.md @@ -42,8 +42,10 @@ generation/agent reads or the existing downstream review machinery. - 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 and autosave cadence. Leaving a panel must - neither discard an intent nor retarget it to the newly selected clip. + 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. @@ -62,10 +64,45 @@ generation/agent reads or the existing downstream review machinery. ## Current milestone and proof limits -This checkpoint begins the separate feature with inspected source, a reproducible -missing-custody receipt and an admitted plan. Runtime implementation remains -pending; no draft-preservation fix, writer guard, screenshot or native acceptance -is claimed. The owner paused new hosted CI spending. Continue local qualification -without changing required main merge gates. No new durable store/schema, model or -embedding dependency, project-switch recovery or real-model quality claim belongs -to this slice. +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/ui/src/lib/components/editor/BeatEditor.svelte b/ui/src/lib/components/editor/BeatEditor.svelte index b6d03fb4..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, @@ -34,24 +33,11 @@ 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, @@ -257,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(); }); @@ -403,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}