diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 347e7e64..af874165 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,10 @@ jobs: working-directory: ui run: npm ci + - name: Frontend dependency audit + working-directory: ui + run: npm audit --audit-level=high + - name: Frontend lint working-directory: ui run: npm run lint @@ -108,6 +112,10 @@ jobs: working-directory: ui run: npm run test + - name: Frontend static build + working-directory: ui + run: npm run build + traceability: name: Decision Traceability runs-on: ubuntu-latest diff --git a/crates/core/src/contracts/README.md b/crates/core/src/contracts/README.md index 067b08ea..24022479 100644 --- a/crates/core/src/contracts/README.md +++ b/crates/core/src/contracts/README.md @@ -220,3 +220,12 @@ Timeline title authoring uses `SetTimelineNodeNameCommand` with a required exact 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. + +## Clip arc and sibling authoring contracts + +`SetTimelineNodeArcsCommand` carries the originally read assigned IDs and owned +field clock. `ReorderTimelineSiblingCommand` binds the selected node, adjacent +neighbor and original complete sibling/parent membership read. The selected +editor projects these reads through optional `arc_read` and `order_read` fields. +Unknown legacy evidence stays distinct from known empty membership; optional +consumption on screenplay receipts never backfills unobserved history. diff --git a/crates/core/src/contracts/mod.rs b/crates/core/src/contracts/mod.rs index 2a89ad41..434d9704 100644 --- a/crates/core/src/contracts/mod.rs +++ b/crates/core/src/contracts/mod.rs @@ -132,9 +132,11 @@ pub use story_arc::{ pub use timeline_command::{ ApplyTimelineChildCommand, ApplyTimelineChildrenCommand, CreateTimelineChildFromParentCommand, CreateTimelineNodeCommand, CreateTimelineRelationshipCommand, DeleteTimelineNodeCommand, - DeleteTimelineRelationshipCommand, SetTimelineNodeLockCommand, SetTimelineNodeNameCommand, - SetTimelineNodeNotesCommand, SetTimelineNodeRangeCommand, SplitTimelineNodeCommand, - TimelineNodeNameRead, TimelineNodeRangeRead, + DeleteTimelineRelationshipCommand, ReorderTimelineSiblingCommand, SetTimelineNodeArcsCommand, + SetTimelineNodeLockCommand, SetTimelineNodeNameCommand, SetTimelineNodeNotesCommand, + SetTimelineNodeRangeCommand, SplitTimelineNodeCommand, TimelineArcMembershipInput, + TimelineNodeNameRead, TimelineNodeRangeRead, TimelineSiblingOrderRead, + TimelineSiblingPlacementRead, }; 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 326849d6..4079a6d2 100644 --- a/crates/core/src/contracts/script_document.rs +++ b/crates/core/src/contracts/script_document.rs @@ -255,6 +255,8 @@ pub struct BibleContextScope { /// revisions also detect edit-and-restore ABA; absence preserves legacy replay. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ScriptGenerationTarget { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arc_membership: Option, pub node_id: crate::timeline::node::NodeId, pub start_ms: u64, pub end_ms: u64, diff --git a/crates/core/src/contracts/script_impact_review.rs b/crates/core/src/contracts/script_impact_review.rs index f5e07a70..6ce9ba1c 100644 --- a/crates/core/src/contracts/script_impact_review.rs +++ b/crates/core/src/contracts/script_impact_review.rs @@ -48,6 +48,10 @@ 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_membership_previous: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arc_membership_current: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub arc_description_applicability_previous: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/core/src/contracts/selected_node_editor.rs b/crates/core/src/contracts/selected_node_editor.rs index 6760fce6..261c9c5b 100644 --- a/crates/core/src/contracts/selected_node_editor.rs +++ b/crates/core/src/contracts/selected_node_editor.rs @@ -45,6 +45,10 @@ pub struct SelectedNodeEditorNode { pub name_read: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub notes_read: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arc_read: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub order_read: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -146,6 +150,8 @@ impl From<&StoryNode> for SelectedNodeEditorNode { range_read: None, name_read: None, notes_read: None, + arc_read: None, + order_read: None, } } } diff --git a/crates/core/src/contracts/timeline_command.rs b/crates/core/src/contracts/timeline_command.rs index 9a2bc344..fe222c4d 100644 --- a/crates/core/src/contracts/timeline_command.rs +++ b/crates/core/src/contracts/timeline_command.rs @@ -1,9 +1,58 @@ use serde::{Deserialize, Serialize}; use crate::ai::backend::ChildPlanId; +use crate::story::arc::ArcId; + +/// Exact selected-clip membership. None is a known history-free baseline. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TimelineArcMembershipInput { + pub node_id: NodeId, + pub arc_ids: Vec, + pub revision_event_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SetTimelineNodeArcsCommand { + pub node_id: NodeId, + pub arc_ids: Vec, + pub expected: TimelineArcMembershipInput, +} + use crate::timeline::node::{BeatType, NodeId, StoryLevel}; use crate::timeline::relationship::{RelationshipId, RelationshipType}; +/// Canonical sibling order/geometry read, including structural membership ABA. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TimelineSiblingOrderRead { + pub node_id: NodeId, + pub parent_id: Option, + pub level: StoryLevel, + pub membership_revision_event_id: Option, + pub siblings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TimelineSiblingPlacementRead { + pub node_id: NodeId, + pub name: String, + pub start_ms: u64, + pub end_ms: u64, + pub sort_order: u32, + pub revision_event_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReorderTimelineSiblingCommand { + pub node_id: NodeId, + pub neighbor_id: NodeId, + pub expected: TimelineSiblingOrderRead, +} + /// Exact canonical title and its owned field clock; None is a known baseline. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/crates/core/src/timeline/README.md b/crates/core/src/timeline/README.md index 25c2988e..e4bcd168 100644 --- a/crates/core/src/timeline/README.md +++ b/crates/core/src/timeline/README.md @@ -67,3 +67,12 @@ use eidetic_core::timeline::Timeline; ## Structured Producer Contract - Timeline nodes, tracks, structure segments, and relationships are persisted and mirrored by the UI. - Ordering and enum semantics must stay stable unless accompanied by coordinated persistence and frontend updates. + +## Atomic adjacent sibling planning + +`sibling_reorder.rs` plans a swap before mutation: equal-parent and equal-level +adjacency, hierarchy/chronological agreement, duration/gap preservation, checked +translation of both subtrees and child containment. Invalid ranges fail without +changing nodes. The server owns revision receipts and atomic persisted writes; +core planning does not reconstruct concurrency evidence. Content locks retain +their AI-generation meaning and structural moves preserve locked content. diff --git a/crates/core/src/timeline/mod.rs b/crates/core/src/timeline/mod.rs index 8fa9d1e5..76b96898 100644 --- a/crates/core/src/timeline/mod.rs +++ b/crates/core/src/timeline/mod.rs @@ -1,5 +1,6 @@ pub mod node; pub mod relationship; +pub mod sibling_reorder; pub mod structure; pub mod timing; pub mod track; diff --git a/crates/core/src/timeline/sibling_reorder.rs b/crates/core/src/timeline/sibling_reorder.rs new file mode 100644 index 00000000..0fb9b80d --- /dev/null +++ b/crates/core/src/timeline/sibling_reorder.rs @@ -0,0 +1,150 @@ +//! Atomic adjacent sibling order and placement, preserving authored content. +use super::{Timeline, node::NodeId, timing::TimeRange}; +use crate::{Error, Result}; + +/// Swap an earlier/later pair without changing durations, gap or outer bounds. +pub fn swapped_adjacent_ranges( + first: TimeRange, + second: TimeRange, +) -> Result<(TimeRange, TimeRange)> { + first.validate()?; + second.validate()?; + if first.end_ms > second.start_ms { + return Err(Error::InvalidOperation( + "Reorder requires non-overlapping adjacent clips".into(), + )); + } + let second_end = first + .start_ms + .checked_add(second.duration_ms()) + .ok_or_else(|| Error::InvalidOperation("Reorder range overflow".into()))?; + let first_start = second_end + .checked_add(second.start_ms - first.end_ms) + .ok_or_else(|| Error::InvalidOperation("Reorder range overflow".into()))?; + Ok(( + TimeRange::new(first_start, second.end_ms)?, + TimeRange::new(first.start_ms, second_end)?, + )) +} + +impl Timeline { + /// Reorder one clip with its adjacent sibling. All validation precedes mutation. + /// Content locks retain their AI meaning and do not prohibit structural moves. + pub fn reorder_adjacent_nodes(&mut self, node_id: NodeId, neighbor_id: NodeId) -> Result<()> { + let node = self.node(node_id)?.clone(); + let neighbor = self.node(neighbor_id)?.clone(); + if node_id == neighbor_id + || node.parent_id.is_none() + || node.parent_id != neighbor.parent_id + || node.level != neighbor.level + { + return Err(Error::InvalidOperation( + "Reorder requires two distinct clips on the same parent and story-level track" + .into(), + )); + } + let mut siblings = self + .nodes + .iter() + .filter(|candidate| { + candidate.parent_id == node.parent_id && candidate.level == node.level + }) + .collect::>(); + siblings.sort_by_key(|candidate| (candidate.time_range.start_ms, candidate.id.0)); + let index = siblings + .iter() + .position(|candidate| candidate.id == node_id) + .expect("node is sibling"); + let neighbor_index = siblings + .iter() + .position(|candidate| candidate.id == neighbor_id) + .expect("neighbor is sibling"); + if index.abs_diff(neighbor_index) != 1 { + return Err(Error::InvalidOperation( + "Selected clips are no longer adjacent".into(), + )); + } + let (first, second) = if index < neighbor_index { + (&node, &neighbor) + } else { + (&neighbor, &node) + }; + let outer = TimeRange::new(first.time_range.start_ms, second.time_range.end_ms)?; + if siblings.iter().any(|candidate| { + candidate.id != node_id + && candidate.id != neighbor_id + && candidate.time_range.overlaps(&outer) + }) { + return Err(Error::InvalidOperation( + "Reorder cannot cross overlapping sibling clips".into(), + )); + } + siblings.sort_by_key(|candidate| { + ( + candidate.sort_order, + candidate.time_range.start_ms, + candidate.id.0, + ) + }); + let first_index = siblings + .iter() + .position(|candidate| candidate.id == first.id) + .expect("first sibling"); + if first.sort_order > second.sort_order + || siblings + .get(first_index + 1) + .is_none_or(|candidate| candidate.id != second.id) + { + return Err(Error::InvalidOperation("Screen placement and sibling order disagree; review their placement before reordering".into())); + } + let parent = self.node(node.parent_id.expect("parent checked"))?; + if outer.start_ms < parent.time_range.start_ms + || outer.end_ms > parent.time_range.end_ms + || outer.end_ms > self.total_duration_ms + { + return Err(Error::InvalidOperation( + "Reorder must remain inside the parent and timeline".into(), + )); + } + let (first_range, second_range) = + swapped_adjacent_ranges(first.time_range, second.time_range)?; + let mut plan = Vec::new(); + for (root, range) in [(first, first_range), (second, second_range)] { + let offset = i128::from(range.start_ms) - i128::from(root.time_range.start_ms); + plan.push((root.id, range)); + for child in self.descendants_of(root.id) { + child.time_range.validate()?; + let owner = self.node(child.parent_id.expect("descendant parent"))?; + if child.time_range.start_ms < owner.time_range.start_ms + || child.time_range.end_ms > owner.time_range.end_ms + { + return Err(Error::InvalidOperation( + "Reorder requires every child to remain contained by its parent".into(), + )); + } + let shifted = |value: u64| { + u64::try_from(i128::from(value) + offset).map_err(|_| { + Error::InvalidOperation("Reorder descendant range overflow".into()) + }) + }; + plan.push(( + child.id, + TimeRange::new( + shifted(child.time_range.start_ms)?, + shifted(child.time_range.end_ms)?, + )?, + )); + } + } + for (id, range) in plan { + self.node_mut(id)?.time_range = range; + } + self.node_mut(first.id)?.sort_order = second.sort_order; + self.node_mut(second.id)?.sort_order = first.sort_order; + Ok(()) + } +} + +#[cfg(test)] +#[path = "sibling_reorder_tests.rs"] +mod tests; diff --git a/crates/core/src/timeline/sibling_reorder_tests.rs b/crates/core/src/timeline/sibling_reorder_tests.rs new file mode 100644 index 00000000..19376fd7 --- /dev/null +++ b/crates/core/src/timeline/sibling_reorder_tests.rs @@ -0,0 +1,103 @@ +use super::*; +use crate::timeline::{ + node::{StoryLevel, StoryNode}, + structure::EpisodeStructure, +}; +fn fixture() -> (Timeline, NodeId, NodeId, NodeId) { + let mut t = Timeline::new(1000, EpisodeStructure::standard_30_min()); + let p = StoryNode::new( + "Parent", + StoryLevel::Premise, + TimeRange::new(0, 1000).unwrap(), + ); + let parent = p.id; + t.add_node(p).unwrap(); + let mut a = StoryNode::new_child( + "A", + StoryLevel::Act, + TimeRange::new(100, 300).unwrap(), + parent, + ); + a.sort_order = 1; + a.locked = true; + a.content.notes = " exact 雨\n".into(); + let first = a.id; + t.add_node(a).unwrap(); + let mut b = StoryNode::new_child( + "B", + StoryLevel::Act, + TimeRange::new(400, 700).unwrap(), + parent, + ); + b.sort_order = 2; + let second = b.id; + t.add_node(b).unwrap(); + let c = StoryNode::new_child( + "Child", + StoryLevel::Sequence, + TimeRange::new(150, 250).unwrap(), + first, + ); + let child = c.id; + t.add_node(c).unwrap(); + (t, first, second, child) +} +#[test] +fn swap_translates_children_preserves_content_and_is_reversible() { + let (mut t, a, b, c) = fixture(); + let before = serde_json::to_value(&t).unwrap(); + t.reorder_adjacent_nodes(a, b).unwrap(); + assert_eq!( + t.node(a).unwrap().time_range, + TimeRange::new(500, 700).unwrap() + ); + assert_eq!( + t.node(b).unwrap().time_range, + TimeRange::new(100, 400).unwrap() + ); + assert_eq!( + t.node(c).unwrap().time_range, + TimeRange::new(550, 650).unwrap() + ); + assert!(t.node(a).unwrap().locked); + assert_eq!(t.node(a).unwrap().content.notes, " exact 雨\n"); + assert_eq!( + t.children_of(t.node(a).unwrap().parent_id.unwrap())[0].id, + b + ); + t.reorder_adjacent_nodes(a, b).unwrap(); + assert_eq!(serde_json::to_value(&t).unwrap(), before); +} +#[test] +fn equal_sort_order_uses_chronological_tie_break() { + let (mut t, a, b, _) = fixture(); + t.node_mut(b).unwrap().sort_order = 1; + t.reorder_adjacent_nodes(a, b).unwrap(); + assert_eq!(t.siblings_of(a)[0].id, b); +} +#[test] +fn malformed_overlap_child_and_order_refuse_without_mutation() { + for case in 0..4 { + let (mut t, a, b, c) = fixture(); + match case { + 0 => t.node_mut(b).unwrap().time_range.start_ms = 200, + 1 => t.node_mut(c).unwrap().time_range.end_ms = 350, + 2 => t.node_mut(b).unwrap().sort_order = 0, + _ => t.node_mut(a).unwrap().time_range.end_ms = 50, + }; + let before = serde_json::to_value(&t).unwrap(); + assert!(t.reorder_adjacent_nodes(a, b).is_err()); + assert_eq!(serde_json::to_value(&t).unwrap(), before); + } +} +#[test] +fn extreme_ranges_are_exact_without_float_rounding() { + let (a, b) = swapped_adjacent_ranges( + TimeRange::new(1, u64::MAX / 2).unwrap(), + TimeRange::new(u64::MAX / 2 + 7, u64::MAX).unwrap(), + ) + .unwrap(); + assert_eq!(a.duration_ms(), u64::MAX / 2 - 1); + assert_eq!(b.duration_ms(), u64::MAX - (u64::MAX / 2 + 7)); + assert_eq!(a.start_ms - b.end_ms, 7); +} diff --git a/crates/server/src/README.md b/crates/server/src/README.md index e5f111b3..b9a614ee 100644 --- a/crates/server/src/README.md +++ b/crates/server/src/README.md @@ -698,3 +698,15 @@ UserEdit history and rejects stale/ABA reads under the shared timeline writer gu recap titles actually supplied before provider I/O. Existing graph edges and proposal bindings support exact impact, stale/ABA refusal, historical/current or owned-deletion evidence, and explicit acceptance with refreshed consumed lineage. + +## Canonical arc assignment and sibling reorder + +`timeline_arc_membership` validates assigned IDs and owned field reads in the +existing history transaction. `timeline_sibling_order` validates the complete +sibling/parent membership read and applies the core two-subtree plan atomically +with source-bound screenplay placement. Sparse owned history and stored original +command receipts preserve replay after later edits, deletion or ABA. +`timeline_arc_assignment_history` and `timeline_sibling_reorder_history` validate +those original receipts instead of rebuilding them from today's timeline. +Existing generation/proposal dependency rows supply targeted review; saved +screenplay is replaced only by ordinary explicit acceptance. diff --git a/crates/server/src/command_service.rs b/crates/server/src/command_service.rs index cb3a85a3..bd6ab670 100644 --- a/crates/server/src/command_service.rs +++ b/crates/server/src/command_service.rs @@ -42,9 +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_name, - set_timeline_node_notes, set_timeline_node_range, split_timeline_node, - split_timeline_node_from_core_command, + delete_timeline_relationship, reorder_timeline_sibling, set_timeline_node_arcs, + 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)] diff --git a/crates/server/src/command_service_timeline.rs b/crates/server/src/command_service_timeline.rs index 95d3d670..e0740cab 100644 --- a/crates/server/src/command_service_timeline.rs +++ b/crates/server/src/command_service_timeline.rs @@ -1,7 +1,8 @@ use eidetic_core::contracts::{ ApplyTimelineChildCommand, CommandEnvelope, CreateTimelineChildFromParentCommand, CreateTimelineNodeCommand, CreateTimelineRelationshipCommand, DeleteTimelineNodeCommand, - DeleteTimelineRelationshipCommand, ObjectKind, ProjectionEnvelope, SetTimelineNodeLockCommand, + DeleteTimelineRelationshipCommand, ObjectKind, ProjectionEnvelope, + ReorderTimelineSiblingCommand, SetTimelineNodeArcsCommand, SetTimelineNodeLockCommand, SetTimelineNodeNameCommand, SetTimelineNodeNotesCommand, SetTimelineNodeRangeCommand, SplitTimelineNodeCommand, TimelineRenderProjection, }; @@ -28,6 +29,10 @@ pub struct TimelineCommandResponse { outcome: RecordChangeOutcome, #[serde(skip_serializing_if = "Option::is_none")] notes_read: Option, + #[serde(skip_serializing_if = "Option::is_none")] + arc_read: Option, + #[serde(skip_serializing_if = "Option::is_none")] + order_read: Option, projection: ProjectionEnvelope, } @@ -91,6 +96,8 @@ async fn create_timeline_node_at_admission( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, + arc_read: None, notes_read: None, projection, }) @@ -155,6 +162,8 @@ pub async fn set_timeline_node_name( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, + arc_read: None, notes_read: None, projection, }) @@ -209,6 +218,8 @@ pub async fn set_timeline_node_range( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, + arc_read: None, notes_read: None, projection, }) @@ -250,6 +261,8 @@ pub async fn set_timeline_node_lock( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, + arc_read: None, notes_read: None, projection, }) @@ -271,6 +284,65 @@ pub async fn set_timeline_node_lock( .await } +pub async fn set_timeline_node_arcs( + 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_arcs_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!("Arc assignment refused: {message}")) + } + BackendError::Conflict(message) => { + BackendError::conflict(format!("Arc assignment refused: {message}")) + } + other => other, + } + })?; + let arc_read = crate::timeline_arc_membership::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, + order_read: None, + arc_read: Some(arc_read), + notes_read: None, + projection, + }) + }) + .await + .map_err(|error| { + BackendError::internal(format!( + "timeline arc assignment 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_notes( state: &AppState, command: CommandEnvelope, @@ -307,6 +379,8 @@ pub async fn set_timeline_node_notes( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, + arc_read: None, notes_read: Some(notes_read), projection, }) @@ -364,6 +438,8 @@ pub async fn delete_timeline_node( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, + arc_read: None, notes_read: None, projection, }) @@ -415,6 +491,8 @@ pub async fn delete_timeline_relationship( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, + arc_read: None, notes_read: None, projection, }) @@ -464,6 +542,8 @@ pub async fn create_timeline_relationship_from_core_command( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, + arc_read: None, notes_read: None, projection, }) @@ -508,6 +588,8 @@ pub async fn apply_timeline_children( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, + arc_read: None, notes_read: None, projection, }) @@ -608,6 +690,8 @@ pub async fn split_timeline_node_from_core_command( .map_err(map_timeline_command_error)?; Ok::<_, BackendError>(TimelineCommandResponse { outcome, + order_read: None, + arc_read: None, notes_read: None, projection, }) @@ -715,3 +799,63 @@ fn map_timeline_command_error(error: TimelineCommandError) -> BackendError { #[cfg(test)] #[path = "timeline_command_admission_tests.rs"] mod admission_tests; + +pub async fn reorder_timeline_sibling( + 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_reorder_timeline_sibling_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!("Reorder refused: {message}")) + } + BackendError::Conflict(message) => { + BackendError::conflict(format!("Reorder refused: {message}")) + } + other => other, + } + })?; + let order_read = crate::timeline_sibling_order::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, + order_read: Some(order_read), + arc_read: None, + notes_read: None, + projection, + }) + }) + .await + .map_err(|error| { + BackendError::internal(format!( + "timeline sibling reorder command task failed: {error}" + )) + })??; + + if response.outcome == RecordChangeOutcome::Recorded { + let _ = state.events_tx.send(ServerEvent::TimelineChanged); + let _ = state.events_tx.send(ServerEvent::HierarchyChanged); + let _ = state.events_tx.send(ServerEvent::ScriptChanged); + state.trigger_save(); + } + Ok(response) + }) + .await +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index ee30967f..f0333881 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -105,6 +105,8 @@ pub(crate) mod story_arc_command; pub(crate) mod story_arc_lineage; pub(crate) mod story_arc_store; pub(crate) mod timeline_affect_overlay; +mod timeline_arc_assignment_history; +mod timeline_arc_membership; pub(crate) mod timeline_child_plan_apply; pub(crate) mod timeline_children_history; pub(crate) mod timeline_command; @@ -117,6 +119,8 @@ pub(crate) mod timeline_node_split_history; pub(crate) mod timeline_node_store; mod timeline_notes_lineage; pub(crate) mod timeline_relationship_store; +mod timeline_sibling_order; +mod timeline_sibling_reorder_history; pub(crate) mod timeline_title_lineage; pub mod validation; pub(crate) mod vector_store; diff --git a/crates/server/src/projection_service.rs b/crates/server/src/projection_service.rs index c29e27a5..d6a500ad 100644 --- a/crates/server/src/projection_service.rs +++ b/crates/server/src/projection_service.rs @@ -383,6 +383,13 @@ fn load_selected_node_editor_at_path( .as_mut() .filter(|node| canonical_node_ids.contains(&node.node_id)) { + node.order_read = Some( + crate::timeline_sibling_order::capture(&tx, node.node_id).map_err(map_history_error)?, + ); + node.arc_read = Some( + crate::timeline_arc_membership::capture(&tx, node.node_id) + .map_err(map_history_error)?, + ); node.notes_read = Some( crate::timeline_notes_lineage::capture(&tx, node.node_id).map_err(map_history_error)?, ); diff --git a/crates/server/src/script_generation_lineage.rs b/crates/server/src/script_generation_lineage.rs index de681a85..d018d2ab 100644 --- a/crates/server/src/script_generation_lineage.rs +++ b/crates/server/src/script_generation_lineage.rs @@ -81,6 +81,14 @@ pub(crate) fn dependencies( }) .collect::, HistoryStoreError>>()?; if let Some(target) = &command.target_binding { + if let Some(input) = &target.arc_membership { + dependencies.push(crate::timeline_arc_membership::dependency( + input, + &command.block.segment_id, + event, + created_at_ms, + )); + } let input = crate::timeline_notes_lineage::from_target(conn, target)?; if let Some(dependency) = crate::timeline_notes_lineage::dependency( &input, diff --git a/crates/server/src/script_generation_target.rs b/crates/server/src/script_generation_target.rs index 9b128728..ffecfd18 100644 --- a/crates/server/src/script_generation_target.rs +++ b/crates/server/src/script_generation_target.rs @@ -34,6 +34,7 @@ fn current( let segment_id = format!("script.segment.{}", node.id.0); let block_id = format!("script.block.{}.generated", node.id.0); Ok(ScriptGenerationTarget { + arc_membership: Some(crate::timeline_arc_membership::capture(conn, node.id)?), node_id: node.id, start_ms: node.time_range.start_ms, end_ms: node.time_range.end_ms, diff --git a/crates/server/src/script_impact_projection.rs b/crates/server/src/script_impact_projection.rs index 339fe621..669cc07e 100644 --- a/crates/server/src/script_impact_projection.rs +++ b/crates/server/src/script_impact_projection.rs @@ -153,6 +153,10 @@ pub(crate) fn load_impact( { causes.push(cause); } + if let Some(cause) = crate::timeline_arc_membership::impact(conn, generation_event_id, segment)? + { + causes.push(cause); + } causes.extend(crate::timeline_title_lineage::impact( conn, generation_event_id, diff --git a/crates/server/src/script_impact_prompt.rs b/crates/server/src/script_impact_prompt.rs index c2d5e328..f7c6a0be 100644 --- a/crates/server/src/script_impact_prompt.rs +++ b/crates/server/src/script_impact_prompt.rs @@ -58,6 +58,7 @@ where { 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::timeline_arc_membership::append_prompt(&mut prompt.user, binding); 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 1aaea3b4..be25124a 100644 --- a/crates/server/src/script_impact_review.rs +++ b/crates/server/src/script_impact_review.rs @@ -191,6 +191,11 @@ fn capture_in_snapshot( crate::arc_description_applicability::capture(conn, node_id, &arcs.current)?, ); Ok(ScriptImpactProposalBinding { + arc_membership_previous: crate::timeline_arc_membership::recorded( + conn, + request.generation_event_id, + )?, + arc_membership_current: Some(crate::timeline_arc_membership::capture(conn, node_id)?), arc_description_applicability_previous, arc_description_applicability_current, timeline_title_previous: titles.previous, @@ -407,6 +412,14 @@ pub(crate) fn accept_bound_proposal( )?; let mut dependencies = script_generation_lineage::dependencies(conn, &generated, event.id, created_at_ms)?; + if let Some(input) = &binding.arc_membership_current { + dependencies.push(crate::timeline_arc_membership::dependency( + input, + &write.segment_id, + event.id, + created_at_ms, + )); + } if let Some(input) = &binding.timeline_notes_current && let Some(dependency) = crate::timeline_notes_lineage::dependency( input, diff --git a/crates/server/src/story_arc_lineage.rs b/crates/server/src/story_arc_lineage.rs index fc9c0420..d8982581 100644 --- a/crates/server/src/story_arc_lineage.rs +++ b/crates/server/src/story_arc_lineage.rs @@ -243,7 +243,11 @@ pub(crate) fn preview_inputs( if crate::story_arc_store::load_arc(conn, &arc_id)?.is_some() { // 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) { + if !arcs.iter().any(|arc| arc.id == arc_id) + && !crate::timeline_arc_membership::permits_withdrawal( + conn, node, generation, 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/timeline_arc_assignment_history.rs b/crates/server/src/timeline_arc_assignment_history.rs new file mode 100644 index 00000000..d19af136 --- /dev/null +++ b/crates/server/src/timeline_arc_assignment_history.rs @@ -0,0 +1,102 @@ +//! Explicit membership authoring through existing node_arcs and sparse revisions. +use crate::{ + history_store::{self, HistoryStoreError, RecordChangeOutcome}, + timeline_arc_membership, + timeline_command::TimelineCommandError, +}; +use eidetic_core::{Project, contracts::*}; +use rusqlite::{Connection, params}; + +pub(crate) fn record_set_timeline_node_arcs_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_arcs")? + { + return Ok(outcome); + } + let node = project.timeline.node(command.payload.node_id)?; + let mut arc_ids = command.payload.arc_ids.clone(); + arc_ids.sort_by_key(|id| id.0); + if arc_ids.windows(2).any(|ids| ids[0] == ids[1]) || command.payload.expected.node_id != node.id + { + return Err(HistoryStoreError::InvalidValue("Invalid clip arc assignment".into()).into()); + } + let mut previous = project + .timeline + .node_arcs + .iter() + .filter(|tag| tag.node_id == node.id) + .map(|tag| tag.arc_id) + .collect::>(); + previous.sort_by_key(|id| id.0); + let event = ChangeEvent::new( + command.id, + ChangeEventKind::UserEdit, + format!("assign story arcs to {}", 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( + "arc_ids", + Some(FieldValue::Text( + serde_json::to_string(&previous).map_err(HistoryStoreError::from)?, + )), + Some(FieldValue::Text( + serde_json::to_string(&arc_ids).map_err(HistoryStoreError::from)?, + )), + )); + Ok(history_store::record_change_with( + conn, + command, + "timeline.node_arcs", + &event, + &[revision], + |tx| { + crate::timeline_command_guard::validate_current_timeline(tx, &project.timeline)?; + let current = timeline_arc_membership::capture_excluding(tx, node.id, Some(event.id))?; + if current != command.payload.expected { + return Err(HistoryStoreError::InvalidValue( + "arc assignment changed; read current arcs before saving".into(), + )); + } + if node.locked { + return Err(HistoryStoreError::InvalidValue( + "clip is locked; unlock before assigning story arcs".into(), + )); + } + if current.arc_ids == arc_ids { + return Err(HistoryStoreError::InvalidValue( + "arc assignment is unchanged".into(), + )); + } + for arc in &arc_ids { + if crate::story_arc_store::load_arc(tx, arc)?.is_none() { + return Err(HistoryStoreError::InvalidValue( + "Selected story arc no longer exists; read current arcs before saving" + .into(), + )); + } + } + tx.execute( + "DELETE FROM node_arcs WHERE node_id=?1", + [node.id.0.to_string()], + )?; + for arc in &arc_ids { + tx.execute( + "INSERT INTO node_arcs(node_id,arc_id) VALUES(?1,?2)", + params![node.id.0.to_string(), arc.0.to_string()], + )?; + } + Ok(()) + }, + )?) +} diff --git a/crates/server/src/timeline_arc_membership.rs b/crates/server/src/timeline_arc_membership.rs new file mode 100644 index 00000000..527b31f5 --- /dev/null +++ b/crates/server/src/timeline_arc_membership.rs @@ -0,0 +1,276 @@ +//! Selected clip arc membership in existing timeline field and generation history. +use eidetic_core::contracts::*; +use eidetic_core::timeline::node::NodeId; +use rusqlite::{Connection, OptionalExtension, params}; + +use crate::history_store::HistoryStoreError; + +fn invalid() -> HistoryStoreError { + HistoryStoreError::InvalidValue( + "Timeline arc membership evidence does not match owned history".into(), + ) +} + +pub(crate) fn validate_history( + conn: &Connection, + input: &TimelineArcMembershipInput, +) -> Result<(), HistoryStoreError> { + if input.arc_ids.windows(2).any(|ids| ids[0].0 >= ids[1].0) { + return Err(invalid()); + } + let Some(event) = input.revision_event_id else { + return Ok(()); + }; + let stored: Option = conn.query_row( + "SELECT f.new_text 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='arc_ids' AND f.new_type='text'", + params![input.node_id.0.to_string(),event.0.to_string()], |row| row.get(0)).optional()?.flatten(); + let mut ids: Vec = + serde_json::from_str(&stored.ok_or_else(invalid)?)?; + ids.sort_by_key(|id| id.0); + if ids != input.arc_ids { + return Err(invalid()); + } + Ok(()) +} + +pub(crate) fn capture( + conn: &Connection, + node: NodeId, +) -> Result { + capture_excluding(conn, node, None) +} + +pub(crate) fn capture_excluding( + conn: &Connection, + node: NodeId, + exclude: Option, +) -> Result { + let live: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM nodes WHERE id=?1)", + [node.0.to_string()], + |row| row.get(0), + )?; + if !live { + return Err(invalid()); + } + let mut statement = + conn.prepare("SELECT arc_id FROM node_arcs WHERE node_id=?1 ORDER BY arc_id")?; + let arc_ids = statement + .query_map([node.0.to_string()], |row| row.get::<_, String>(0))? + .map(|id| { + uuid::Uuid::parse_str(&id?) + .map(eidetic_core::story::arc::ArcId) + .map_err(|error| HistoryStoreError::InvalidId(error.to_string())) + }) + .collect::, _>>()?; + 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 + JOIN object_revision_fields f ON f.revision_id=r.id + WHERE r.object_kind='timeline_node' AND r.object_id=?1 AND f.field_key='arc_ids' + AND (?2 IS NULL OR e.id != ?2) ORDER BY e.rowid DESC,r.rowid DESC LIMIT 1", + params![node.0.to_string(),exclude.map(|id| id.0.to_string())],|row|row.get(0)).optional()?; + let input = TimelineArcMembershipInput { + node_id: node, + arc_ids, + revision_event_id: event + .map(|event| { + uuid::Uuid::parse_str(&event) + .map(ChangeEventId) + .map_err(|error| HistoryStoreError::InvalidId(error.to_string())) + }) + .transpose()?, + }; + validate_history(conn, &input)?; + Ok(input) +} + +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 mut arc_ids = command.payload.arc_ids.clone(); + arc_ids.sort_by_key(|id| id.0); + let input = TimelineArcMembershipInput { + node_id: command.payload.node_id, + arc_ids, + revision_event_id: Some(ChangeEventId( + uuid::Uuid::parse_str(&event).map_err(|_| invalid())?, + )), + }; + validate_history(conn, &input)?; + Ok(input) +} + +/// Missing legacy receipts stay unknown; never infer original tags from current state. +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 input = match kind.as_str() { + "script.generate_block" => serde_json::from_str::(&json)? + .target_binding + .and_then(|target| target.arc_membership), + "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_membership_current + } + _ => None, + }; + if let Some(input) = &input { + validate_history(conn, input)?; + } + Ok(input) +} + +fn dependency_id(event: ChangeEventId) -> SemanticDependencyId { + SemanticDependencyId::new(format!("generation.{}.arc_membership", event.0)) + .expect("membership dependency") +} + +pub(crate) fn dependency( + input: &TimelineArcMembershipInput, + segment: &ScriptSegmentId, + event: ChangeEventId, + created_at_ms: u64, +) -> SemanticDependency { + SemanticDependency { + id: dependency_id(event), + source: SemanticDependencyEndpoint::ScriptSegment { + segment_id: segment.clone(), + }, + target: SemanticDependencyEndpoint::TimelineNode { + node_id: input.node_id, + }, + kind: SemanticDependencyKind::DerivesFrom, + rationale: Some( + "Selected clip story arc membership supplied to screenplay generation".into(), + ), + confidence: None, + created_at_ms, + // Known baseline membership is owned by its recorded generation receipt. + revision_binding: Some(SemanticDependencyRevisionBinding { + source_revision_event_id: event, + target_revision_event_id: input.revision_event_id.unwrap_or(event), + }), + } +} + +pub(crate) fn impact( + conn: &Connection, + event: ChangeEventId, + segment: &ScriptSegmentId, +) -> Result, HistoryStoreError> { + let Some(input) = recorded(conn, event)? else { + return Ok(None); + }; + let source: Option = conn.query_row( + "SELECT source_node_id FROM script_segments WHERE id=?1", + [segment.as_str()], + |row| row.get(0), + )?; + if source.as_deref() != Some(input.node_id.0.to_string().as_str()) { + return Err(invalid()); + } + 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 { + return Ok(Some(ScriptImpactCause { + dependency_id: dependency_id(event), + input: SemanticDependencyEndpoint::TimelineNode { + node_id: input.node_id, + }, + consumed_revision_event_id: input.revision_event_id.unwrap_or(event), + current_revision_event_id: None, + reason: ScriptImpactReason::Deleted, + input_excerpt: Some("Selected clip story arc assignment source was removed.".into()), + })); + } + let current = capture(conn, input.node_id)?; + if current == input { + return Ok(None); + } + Ok(Some(ScriptImpactCause { + dependency_id: dependency_id(event), + input: SemanticDependencyEndpoint::TimelineNode { + node_id: input.node_id, + }, + consumed_revision_event_id: input.revision_event_id.unwrap_or(event), + current_revision_event_id: current.revision_event_id, + reason: ScriptImpactReason::ContextChanged, + input_excerpt: Some("Selected clip story arc assignment changed.".into()), + })) +} + +/// Allow withdrawal of live arc prose only when the immutable consumed membership +/// and current canonical receipt explicitly prove that arc left this clip. +pub(crate) fn permits_withdrawal( + conn: &Connection, + node: NodeId, + generation: ChangeEventId, + arc: eidetic_core::story::arc::ArcId, +) -> Result { + let Some(previous) = recorded(conn, generation)? else { + return Ok(false); + }; + let current = capture(conn, node)?; + Ok(current.revision_event_id.is_some() + && previous.node_id == node + && previous.arc_ids.contains(&arc) + && !current.arc_ids.contains(&arc)) +} + +/// Prompt evidence comes exclusively from the captured proposal binding. +pub(crate) fn append_prompt(user: &mut String, binding: &ScriptImpactProposalBinding) { + for (label, input, fields) in [ + ( + "ORIGINAL RECORDED STORY ARC ASSIGNMENT (historical, not current guidance)", + binding.arc_membership_previous.as_ref(), + binding.arc_previous_inputs.as_deref(), + ), + ( + "CURRENT SELECTED CLIP STORY ARC ASSIGNMENT", + binding.arc_membership_current.as_ref(), + binding.arc_inputs.as_deref(), + ), + ] { + let Some(input) = input else { + continue; + }; + user.push_str(&format!("\n{label} (clip {}):\n", input.node_id.0)); + if input.arc_ids.is_empty() { + user.push_str("(no arcs)\n"); + } + for arc in &input.arc_ids { + let name = fields + .unwrap_or_default() + .iter() + .find(|field| field.arc_id == *arc && field.field == StoryArcPromptField::Name) + .map(|field| field.value.as_str()) + .unwrap_or("name unavailable"); + user.push_str(&format!("{} name: {}\n", arc.0, name)); + } + } + if binding.arc_membership_previous.is_some() && binding.arc_membership_current.is_some() { + user.push_str("Arcs absent from the current assignment no longer guide this update.\n"); + } +} + +#[cfg(test)] +#[path = "timeline_arc_membership_tests.rs"] +mod tests; diff --git a/crates/server/src/timeline_arc_membership_tests.rs b/crates/server/src/timeline_arc_membership_tests.rs new file mode 100644 index 00000000..708396e3 --- /dev/null +++ b/crates/server/src/timeline_arc_membership_tests.rs @@ -0,0 +1,477 @@ +use super::*; +use crate::script_impact_review::tests::{accept, request}; +use crate::timeline_notes_lineage::tests::{logical_rows, materials}; +use crate::{ + script_document_command, script_impact_review, story_arc_command, + timeline_arc_assignment_history, timeline_node_store, +}; +use eidetic_core::{ + Project, + story::arc::{ArcId, ArcType, Color}, +}; + +fn create(conn: &mut Connection, name: &str) -> 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: name.into(), + description: format!("{name} exact authored arc — 雨."), + arc_type: ArcType::APlot, + color: Color::new(100, 149, 237), + }), + 1, + ) + .unwrap(); + arc_id +} +fn refresh(conn: &Connection, project: &mut Project) { + project.timeline.nodes = timeline_node_store::load_nodes(conn).unwrap(); + project.timeline.node_arcs = timeline_node_store::load_node_arcs(conn).unwrap(); +} +fn assignment( + conn: &Connection, + node: NodeId, + arc_ids: Vec, +) -> CommandEnvelope { + CommandEnvelope::new(SetTimelineNodeArcsCommand { + node_id: node, + arc_ids, + expected: capture(conn, node).unwrap(), + }) +} +fn assign( + conn: &mut Connection, + project: &mut Project, + node: NodeId, + ids: Vec, +) -> CommandEnvelope { + refresh(conn, project); + let command = assignment(conn, node, ids); + timeline_arc_assignment_history::record_set_timeline_node_arcs_history( + conn, project, &command, 10, + ) + .unwrap(); + refresh(conn, project); + command +} +fn setup() -> ( + Connection, + Project, + CommandEnvelope, + ArcId, + ArcId, +) { + let (mut conn, project, generation) = crate::script_generation_target::tests::fixture(); + let a = create(&mut conn, "Witness"); + let b = create(&mut conn, "Departure"); + (conn, project, generation, a, b) +} +fn generate( + conn: &mut Connection, + project: &mut Project, + generation: &mut CommandEnvelope, +) { + refresh(conn, project); + let node = generation.payload.target_binding.as_ref().unwrap().node_id; + generation.payload.target_binding = Some( + crate::script_generation_target::capture(conn, project.timeline.node(node).unwrap()) + .unwrap(), + ); + generation.payload.arc_inputs = Some( + crate::story_arc_lineage::capture(conn, node, &[]) + .unwrap() + .1, + ); + script_document_command::apply_generated_script_block(conn, generation, 20).unwrap(); +} +fn review( + conn: &Connection, + generation: &CommandEnvelope, +) -> ScriptImpactProjection { + crate::script_impact_projection::load_impact(conn, &generation.payload.block.segment_id) + .unwrap() + .unwrap() +} +#[test] +fn first_assignment_last_removal_and_accepted_successor_keep_membership_custody() { + let (mut conn, mut project, mut generation, a, b) = setup(); + let node = generation.payload.target_binding.as_ref().unwrap().node_id; + generate(&mut conn, &mut project, &mut generation); + assert!(!review(&conn, &generation).needs_review); + let original = materials(&conn); + assign(&mut conn, &mut project, node, vec![a]); + assert_eq!(materials(&conn), original); + assert_eq!(review(&conn, &generation).causes.len(), 1); + let request = request(&conn, &generation.payload.block); + let binding = script_impact_review::capture(&conn, &request.payload).unwrap(); + assert!( + binding + .arc_membership_previous + .as_ref() + .unwrap() + .arc_ids + .is_empty() + ); + assert_eq!( + binding.arc_membership_current.as_ref().unwrap().arc_ids, + vec![a] + ); + script_impact_review::record_proposal( + &mut conn, + &request, + binding, + "Labelled synthetic first assignment preview".into(), + 30, + ) + .unwrap(); + assert_eq!(materials(&conn), original); + accept(&mut conn, &request).unwrap(); + assert!(!review(&conn, &generation).needs_review); + assign(&mut conn, &mut project, node, vec![]); + let review = review(&conn, &generation); + assert!(review.needs_review); + let second = request_for( + &conn, + &generation, + review + .causes + .iter() + .find(|cause| cause.dependency_id.as_str().ends_with(".arc_membership")) + .unwrap() + .dependency_id + .clone(), + ); + let binding = script_impact_review::capture(&conn, &second.payload).unwrap(); + assert_eq!(binding.arc_membership_previous.unwrap().arc_ids, vec![a]); + assert!(binding.arc_membership_current.unwrap().arc_ids.is_empty()); + assert!(binding.arc_inputs.unwrap().is_empty()); + assert!(permits_withdrawal(&conn, node, second.payload.generation_event_id, a).unwrap()); + assign(&mut conn, &mut project, node, vec![b]); + assert!(review_again(&conn, &generation)); +} +fn request_for( + conn: &Connection, + generation: &CommandEnvelope, + id: SemanticDependencyId, +) -> CommandEnvelope { + let mut command = request(conn, &generation.payload.block); + command.payload.dependency_id = id; + command +} +fn review_again( + conn: &Connection, + generation: &CommandEnvelope, +) -> bool { + review(conn, generation).needs_review +} +#[test] +fn save_is_atomic_deduplicated_and_replay_returns_original_receipt_after_newer_assignment() { + let (mut conn, mut project, generation, a, b) = setup(); + let node = generation.payload.target_binding.as_ref().unwrap().node_id; + let first = assign(&mut conn, &mut project, node, vec![a]); + let receipt = command_receipt(&conn, &first).unwrap(); + assign(&mut conn, &mut project, node, vec![b]); + let before = logical_rows(&conn); + assert_eq!( + timeline_arc_assignment_history::record_set_timeline_node_arcs_history( + &mut conn, &project, &first, 40 + ) + .unwrap(), + crate::history_store::RecordChangeOutcome::AlreadyRecorded + ); + assert_eq!(command_receipt(&conn, &first).unwrap(), receipt); + assert_eq!(logical_rows(&conn), before); + for ids in [vec![a, a], vec![ArcId::new()], vec![b]] { + let command = assignment(&conn, node, ids); + let before = logical_rows(&conn); + assert!( + timeline_arc_assignment_history::record_set_timeline_node_arcs_history( + &mut conn, &project, &command, 40 + ) + .is_err() + ); + assert_eq!(logical_rows(&conn), before); + } +} +#[test] +fn stale_save_and_aba_refuse_without_rows_or_manual_screenplay_changes() { + let (mut conn, mut project, generation, a, b) = setup(); + let node = generation.payload.target_binding.as_ref().unwrap().node_id; + let stale = assignment(&conn, node, vec![b]); + assign(&mut conn, &mut project, node, vec![a]); + assign(&mut conn, &mut project, node, vec![]); + let before = logical_rows(&conn); + assert!( + timeline_arc_assignment_history::record_set_timeline_node_arcs_history( + &mut conn, &project, &stale, 50 + ) + .is_err() + ); + assert_eq!(logical_rows(&conn), before); +} +#[test] +fn provider_pending_generation_preview_and_acceptance_refuse_membership_aba() { + let (mut conn, mut project, mut generation, a, b) = setup(); + let node = generation.payload.target_binding.as_ref().unwrap().node_id; + assign(&mut conn, &mut project, node, vec![a]); + refresh(&conn, &mut project); + generation.payload.target_binding = Some( + crate::script_generation_target::capture(&conn, project.timeline.node(node).unwrap()) + .unwrap(), + ); + generation.payload.arc_inputs = Some( + crate::story_arc_lineage::capture(&conn, node, &[]) + .unwrap() + .1, + ); + assign(&mut conn, &mut project, node, vec![b]); + assign(&mut conn, &mut project, node, vec![a]); + let before = logical_rows(&conn); + assert!( + script_document_command::apply_generated_script_block(&mut conn, &generation, 20).is_err() + ); + assert_eq!(logical_rows(&conn), before); + generate(&mut conn, &mut project, &mut generation); + assign(&mut conn, &mut project, node, vec![b]); + let command = request(&conn, &generation.payload.block); + let binding = script_impact_review::capture(&conn, &command.payload).unwrap(); + assign(&mut conn, &mut project, node, vec![a]); + assign(&mut conn, &mut project, node, vec![b]); + crate::propagation_proposal_store::create_schema(&conn).unwrap(); + let before = logical_rows(&conn); + assert!( + script_impact_review::record_proposal( + &mut conn, + &command, + binding, + "Labelled synthetic stale result".into(), + 60 + ) + .is_err() + ); + assert_eq!(logical_rows(&conn), before); + let command = request(&conn, &generation.payload.block); + let binding = script_impact_review::capture(&conn, &command.payload).unwrap(); + script_impact_review::record_proposal( + &mut conn, + &command, + binding, + "Labelled synthetic pending preview".into(), + 60, + ) + .unwrap(); + assign(&mut conn, &mut project, node, vec![a]); + assign(&mut conn, &mut project, node, vec![b]); + let before = logical_rows(&conn); + assert!( + crate::propagation_proposal_accept::record_accept_propagation_proposal( + &mut conn, + &CommandEnvelope::new(AcceptPropagationProposalCommand { + proposal_id: command.payload.proposal_id + }), + 70 + ) + .is_err() + ); + assert_eq!(logical_rows(&conn), before); +} +#[test] +fn unrelated_clip_assignment_does_not_stale_target_and_legacy_consumption_stays_unknown() { + let (mut conn, mut project, mut generation, a, b) = setup(); + let node = generation.payload.target_binding.as_ref().unwrap().node_id; + assign(&mut conn, &mut project, node, vec![a]); + generate(&mut conn, &mut project, &mut generation); + let other = project + .timeline + .nodes + .iter() + .find(|candidate| candidate.id != node) + .unwrap() + .id; + assign(&mut conn, &mut project, other, vec![b]); + assert!(!review(&conn, &generation).needs_review); + generation.id = CommandId(uuid::Uuid::new_v4()); + generation.payload.target_binding = None; + script_document_command::apply_generated_script_block(&mut conn, &generation, 80).unwrap(); + assign(&mut conn, &mut project, node, vec![]); + assert!( + recorded(&conn, review(&conn, &generation).generation_event_id) + .unwrap() + .is_none() + ); + assert!( + !permits_withdrawal( + &conn, + node, + review(&conn, &generation).generation_event_id, + a + ) + .unwrap() + ); + // A known arc-field edit still exposes the legacy review, but removal cannot + // broaden the guarded prompt into unverified withdrawal. + story_arc_command::record_set_story_arc_metadata_history( + &mut conn, + &CommandEnvelope::new(SetStoryArcMetadataCommand { + arc_id: a, + name: Some("Changed name".into()), + description: None, + arc_type: None, + color: None, + }), + 90, + ) + .unwrap(); + let command = request(&conn, &generation.payload.block); + assert!(script_impact_review::capture(&conn, &command.payload).is_err()); +} + +#[test] +fn locked_clip_refuses_fresh_assignment_but_original_acknowledgement_replays() { + let (mut conn, mut project, generation, a, b) = setup(); + let node = generation.payload.target_binding.as_ref().unwrap().node_id; + let original = assign(&mut conn, &mut project, node, vec![a]); + let receipt = command_receipt(&conn, &original).unwrap(); + crate::timeline_command_history::record_set_timeline_node_lock_history( + &mut conn, + &project, + &CommandEnvelope::new(SetTimelineNodeLockCommand { + node_id: node, + locked: true, + }), + 80, + ) + .unwrap(); + refresh(&conn, &mut project); + let fresh = assignment(&conn, node, vec![b]); + let before = logical_rows(&conn); + assert!( + timeline_arc_assignment_history::record_set_timeline_node_arcs_history( + &mut conn, &project, &fresh, 90 + ) + .is_err() + ); + assert_eq!(logical_rows(&conn), before); + assert_eq!( + timeline_arc_assignment_history::record_set_timeline_node_arcs_history( + &mut conn, &project, &original, 90 + ) + .unwrap(), + crate::history_store::RecordChangeOutcome::AlreadyRecorded + ); + assert_eq!(command_receipt(&conn, &original).unwrap(), receipt); + assert_eq!(logical_rows(&conn), before); +} + +#[tokio::test] +async fn last_removal_provider_receives_historical_identity_and_explicit_current_empty_assignment() +{ + let (mut conn, mut project, mut generation, a, _) = setup(); + let node = generation.payload.target_binding.as_ref().unwrap().node_id; + assign(&mut conn, &mut project, node, vec![a]); + generate(&mut conn, &mut project, &mut generation); + assign(&mut conn, &mut project, node, vec![]); + let command = request(&conn, &generation.payload.block); + let binding = script_impact_review::capture(&conn, &command.payload).unwrap(); + let output = + crate::script_impact_prompt::preview_with_provider(&binding, |prompt| async move { + assert!(prompt.user.contains( + "ORIGINAL RECORDED STORY ARC ASSIGNMENT (historical, not current guidance)" + )); + assert!(prompt.user.contains(&format!("{} name: Witness", a.0))); + assert!( + prompt + .user + .contains("CURRENT SELECTED CLIP STORY ARC ASSIGNMENT") + ); + assert!(prompt.user.contains("(no arcs)")); + assert!(!prompt.user.contains("CURRENT TAGGED STORY ARC FIELDS")); + assert!(!prompt.user.contains("Witness exact authored arc")); + let stream: eidetic_core::ai::backend::GenerateStream = Box::pin( + futures::stream::iter(vec![Ok("LABELLED SYNTHETIC last removal update".into())]), + ); + Ok(stream) + }) + .await + .unwrap(); + assert_eq!(output, "LABELLED SYNTHETIC last removal update"); +} + +#[test] +fn forged_wrong_node_or_field_receipts_and_unlogged_tag_mutation_refuse() { + let (mut conn, mut project, mut generation, a, b) = setup(); + let node = generation.payload.target_binding.as_ref().unwrap().node_id; + assign(&mut conn, &mut project, node, vec![a]); + generate(&mut conn, &mut project, &mut generation); + let generation_event = review(&conn, &generation).generation_event_id; + let receipt = capture(&conn, node).unwrap(); + let other = project + .timeline + .nodes + .iter() + .find(|candidate| candidate.id != node) + .unwrap() + .id; + assign(&mut conn, &mut project, other, vec![b]); + let mut forged = receipt.clone(); + forged.revision_event_id = capture(&conn, other).unwrap().revision_event_id; + assert!(validate_history(&conn, &forged).is_err()); + crate::timeline_notes_lineage::tests::notes(&mut conn, &mut project, node, "New Notes"); + forged.revision_event_id = crate::timeline_notes_lineage::capture(&conn, node) + .unwrap() + .revision_event_id; + assert!(validate_history(&conn, &forged).is_err()); + // Deliberate corrupted fixture, not a public authoring path: raw tag removal + // cannot masquerade as a proven owned transition or broaden the prompt. + conn.execute( + "DELETE FROM node_arcs WHERE node_id=?1", + [node.0.to_string()], + ) + .unwrap(); + assert!(capture(&conn, node).is_err()); + assert!(permits_withdrawal(&conn, node, generation_event, a).is_err()); +} + +#[test] +fn inherited_legacy_arc_field_order_normalizes_as_membership_set() { + let (mut conn, mut project, generation, a, b) = setup(); + let node = generation.payload.target_binding.as_ref().unwrap().node_id; + assign(&mut conn, &mut project, node, vec![a, b]); + let mut reverse = vec![a, b]; + reverse.sort_by_key(|id| std::cmp::Reverse(id.0)); + let command = CommandEnvelope::new(SetTimelineNodeLockCommand { + node_id: node, + locked: false, + }); + let event = ChangeEvent::new( + command.id, + ChangeEventKind::UserEdit, + "synthetic legacy inherited tag order", + ); + let revision = ObjectRevision::new( + ObjectKind::TimelineNode, + node.0.to_string(), + event.id, + RevisionOperation::Update, + ) + .with_field(FieldDelta::new( + "arc_ids", + None, + Some(FieldValue::Text(serde_json::to_string(&reverse).unwrap())), + )); + crate::history_store::record_change( + &mut conn, + &command, + "test.legacy_inherited_tags", + &event, + &[revision], + ) + .unwrap(); + let read = capture(&conn, node).unwrap(); + assert_eq!(read.revision_event_id, Some(event.id)); + assert!(read.arc_ids[0].0 < read.arc_ids[1].0); + validate_history(&conn, &read).unwrap(); +} diff --git a/crates/server/src/timeline_command.rs b/crates/server/src/timeline_command.rs index 37cb3434..ac2e1b29 100644 --- a/crates/server/src/timeline_command.rs +++ b/crates/server/src/timeline_command.rs @@ -17,6 +17,7 @@ use eidetic_core::timeline::timing::TimeRange; use thiserror::Error; use crate::history_store::HistoryStoreError; +pub(crate) use crate::timeline_arc_assignment_history::record_set_timeline_node_arcs_history; pub(crate) use crate::timeline_children_history::record_apply_timeline_children_history; pub(crate) use crate::timeline_command_history::{ record_create_timeline_node_history, record_create_timeline_relationship_history, @@ -26,6 +27,7 @@ pub(crate) use crate::timeline_command_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; +pub(crate) use crate::timeline_sibling_reorder_history::record_reorder_timeline_sibling_history; #[cfg(test)] pub(crate) fn apply_set_timeline_node_range( diff --git a/crates/server/src/timeline_sibling_order.rs b/crates/server/src/timeline_sibling_order.rs new file mode 100644 index 00000000..69892497 --- /dev/null +++ b/crates/server/src/timeline_sibling_order.rs @@ -0,0 +1,131 @@ +//! Owned sibling snapshots and immutable command receipts; no parallel order state. +use crate::{history_store::HistoryStoreError, timeline_node_store}; +use eidetic_core::{ + contracts::*, + timeline::{node::NodeId, timing::TimeRange}, +}; +use rusqlite::{Connection, OptionalExtension, params}; +fn invalid(message: &str) -> HistoryStoreError { + HistoryStoreError::InvalidValue(message.into()) +} +pub(crate) fn capture( + conn: &Connection, + node: NodeId, +) -> Result { + capture_excluding(conn, node, None) +} +pub(crate) fn capture_excluding( + conn: &Connection, + node: NodeId, + exclude: Option, +) -> Result { + let nodes = timeline_node_store::load_nodes(conn)?; + let selected = nodes + .iter() + .find(|n| n.id == node) + .ok_or_else(|| invalid("Clip no longer exists"))?; + let mut siblings = nodes + .iter() + .filter(|n| n.parent_id == selected.parent_id && n.level == selected.level) + .map(|n| { + Ok(TimelineSiblingPlacementRead { + node_id: n.id, + name: n.name.clone(), + start_ms: n.time_range.start_ms, + end_ms: n.time_range.end_ms, + sort_order: n.sort_order, + revision_event_id: timeline_node_store::latest_node_event(conn, n.id, exclude)?, + }) + }) + .collect::, HistoryStoreError>>()?; + siblings.sort_by_key(|n| (n.start_ms, n.node_id.0)); + let membership:Option=conn.query_row("SELECT r.change_event_id FROM object_revisions r JOIN object_revision_fields f ON f.revision_id=r.id JOIN change_events e ON e.id=r.change_event_id WHERE r.object_kind='timeline_node' AND f.field_key='parent_id' AND (f.old_text=?1 OR f.new_text=?1) AND (?2 IS NULL OR e.id != ?2) ORDER BY e.rowid DESC,r.rowid DESC LIMIT 1",params![selected.parent_id.map(|id|id.0.to_string()),exclude.map(|id|id.0.to_string())],|r|r.get(0)).optional()?; + Ok(TimelineSiblingOrderRead { + node_id: node, + parent_id: selected.parent_id, + level: selected.level, + membership_revision_event_id: membership + .map(|id| { + uuid::Uuid::parse_str(&id) + .map(ChangeEventId) + .map_err(|e| HistoryStoreError::InvalidId(e.to_string())) + }) + .transpose()?, + siblings, + }) +} +pub(crate) fn command_receipt( + conn: &Connection, + command: &CommandEnvelope, +) -> Result { + let (event,kind,json):(String,String,String) = conn.query_row("SELECT e.id,c.payload_type,c.payload_json FROM change_events e JOIN commands c ON c.id=e.command_id WHERE e.command_id=?1",[command.id.0.to_string()],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?; + if kind != "timeline.sibling_reorder" + || serde_json::from_str::(&json)? != command.payload + { + return Err(invalid( + "Reorder receipt command differs from owned history", + )); + } + let event = ChangeEventId( + uuid::Uuid::parse_str(&event).map_err(|e| HistoryStoreError::InvalidId(e.to_string()))?, + ); + let mut receipt = command.payload.expected.clone(); + let a = receipt + .siblings + .iter() + .position(|n| n.node_id == command.payload.node_id) + .ok_or_else(|| invalid("Missing original selected clip"))?; + let b = receipt + .siblings + .iter() + .position(|n| n.node_id == command.payload.neighbor_id) + .ok_or_else(|| invalid("Missing original neighbor"))?; + let (first, second) = if a < b { (a, b) } else { (b, a) }; + let (a_range, b_range) = eidetic_core::timeline::sibling_reorder::swapped_adjacent_ranges( + TimeRange::new( + receipt.siblings[first].start_ms, + receipt.siblings[first].end_ms, + ) + .map_err(|e| invalid(&e.to_string()))?, + TimeRange::new( + receipt.siblings[second].start_ms, + receipt.siblings[second].end_ms, + ) + .map_err(|e| invalid(&e.to_string()))?, + ) + .map_err(|e| invalid(&e.to_string()))?; + let first_order = receipt.siblings[first].sort_order; + receipt.siblings[first].sort_order = receipt.siblings[second].sort_order; + receipt.siblings[second].sort_order = first_order; + for (index, range) in [(first, a_range), (second, b_range)] { + let n = &mut receipt.siblings[index]; + n.start_ms = range.start_ms; + n.end_ms = range.end_ms; + n.revision_event_id = Some(event); + let stored_order:Option=conn.query_row("SELECT f.new_integer 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='sort_order'",params![n.node_id.0.to_string(),event.0.to_string()],|r|r.get(0)).optional()?; + let previous_order = command + .payload + .expected + .siblings + .iter() + .find(|old| old.node_id == n.node_id) + .expect("original receipt node") + .sort_order; + if stored_order != (previous_order != n.sort_order).then_some(i64::from(n.sort_order)) { + return Err(invalid( + "Reorder order receipt differs from owned sparse history", + )); + } + for (field, value) in [("start_ms", range.start_ms), ("end_ms", range.end_ms)] { + let stored:i64=conn.query_row("SELECT f.new_integer 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=?3",params![n.node_id.0.to_string(),event.0.to_string(),field],|r|r.get(0))?; + if u64::try_from(stored).ok() != Some(value) { + return Err(invalid("Reorder receipt differs from owned history")); + } + } + } + receipt.siblings.sort_by_key(|n| (n.start_ms, n.node_id.0)); + Ok(receipt) +} +#[cfg(test)] +#[path = "timeline_sibling_order_tests.rs"] +mod tests; diff --git a/crates/server/src/timeline_sibling_order_tests.rs b/crates/server/src/timeline_sibling_order_tests.rs new file mode 100644 index 00000000..2069f0d3 --- /dev/null +++ b/crates/server/src/timeline_sibling_order_tests.rs @@ -0,0 +1,320 @@ +use super::*; +use crate::{ + history_store::RecordChangeOutcome, timeline_node_store, + timeline_sibling_reorder_history::record_reorder_timeline_sibling_history as save, +}; +use eidetic_core::Project; +fn refresh(conn: &Connection, p: &mut Project) { + p.timeline.nodes = timeline_node_store::load_nodes(conn).unwrap(); +} +fn command( + conn: &Connection, + a: NodeId, + b: NodeId, +) -> CommandEnvelope { + CommandEnvelope::new(ReorderTimelineSiblingCommand { + node_id: a, + neighbor_id: b, + expected: capture(conn, a).unwrap(), + }) +} +fn snapshot(conn: &Connection) -> (String, Vec) { + let materials = + serde_json::to_string(&crate::timeline_notes_lineage::tests::logical_rows(conn)).unwrap(); + let counts = [ + "commands", + "change_events", + "object_revisions", + "object_revision_fields", + ] + .map(|table| { + conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) + .unwrap() + }); + (materials, counts.to_vec()) +} +#[test] +fn both_placements_preserve_authored_material_and_original_receipt_after_reorder_aba() { + let (mut conn, mut p, a, b) = crate::timeline_script_placement::tests::fixture(); + let a = NodeId(uuid::Uuid::parse_str(a.source_node_id.as_ref().unwrap()).unwrap()); + let b = NodeId(uuid::Uuid::parse_str(b.source_node_id.as_ref().unwrap()).unwrap()); + refresh(&conn, &mut p); + let before = authored(&conn); + let cmd = command(&conn, a, b); + assert_eq!( + save(&mut conn, &p, &cmd, 50).unwrap(), + RecordChangeOutcome::Recorded + ); + let receipt = command_receipt(&conn, &cmd).unwrap(); + assert_eq!(authored(&conn), before); + assert_eq!(receipt.siblings[0].node_id, b); + assert_eq!(receipt.siblings[0].start_ms, 1000); + assert_eq!(receipt.siblings[1].start_ms, 4000); + refresh(&conn, &mut p); + let reverse = command(&conn, a, b); + save(&mut conn, &p, &reverse, 60).unwrap(); + refresh(&conn, &mut p); + let snap = snapshot(&conn); + assert_eq!( + save(&mut conn, &p, &cmd, 70).unwrap(), + RecordChangeOutcome::AlreadyRecorded + ); + assert_eq!(command_receipt(&conn, &cmd).unwrap(), receipt); + assert_eq!(snapshot(&conn), snap); + let mut forged = cmd.clone(); + forged.payload.neighbor_id = a; + assert!(save(&mut conn, &p, &forged, 80).is_err()); + assert_eq!(snapshot(&conn), snap); + let stale = CommandEnvelope::new(cmd.payload.clone()); + assert!(save(&mut conn, &p, &stale, 90).is_err()); + assert_eq!(snapshot(&conn), snap); +} +#[test] +fn structural_moves_allow_content_locks_and_stale_neighbor_notes_refuse() { + let (mut conn, mut p, a, b) = crate::timeline_script_placement::tests::fixture(); + let a = NodeId(uuid::Uuid::parse_str(a.source_node_id.as_ref().unwrap()).unwrap()); + let b = NodeId(uuid::Uuid::parse_str(b.source_node_id.as_ref().unwrap()).unwrap()); + refresh(&conn, &mut p); + let stale = command(&conn, a, b); + crate::timeline_command_history::record_set_timeline_node_notes_history( + &mut conn, + &p, + &CommandEnvelope::new(SetTimelineNodeNotesCommand { + node_id: b, + notes: "new notes 雨".into(), + expected: None, + }), + 51, + ) + .unwrap(); + refresh(&conn, &mut p); + let before = snapshot(&conn); + assert!(save(&mut conn, &p, &stale, 52).is_err()); + assert_eq!(snapshot(&conn), before); + crate::timeline_command_history::record_set_timeline_node_lock_history( + &mut conn, + &p, + &CommandEnvelope::new(SetTimelineNodeLockCommand { + node_id: a, + locked: true, + }), + 53, + ) + .unwrap(); + refresh(&conn, &mut p); + let fresh = command(&conn, a, b); + save(&mut conn, &p, &fresh, 54).unwrap(); + refresh(&conn, &mut p); + assert!(p.timeline.node(a).unwrap().locked); +} +#[test] +fn reordered_scene_context_requires_targeted_review_and_explicit_acceptance() { + let (mut conn, mut p, blocks) = crate::script_context_scope::tests::fixture(); + refresh(&conn, &mut p); + let b = crate::script_context_scope::tests::node(&blocks[1]); + let f = crate::script_context_scope::tests::node(&blocks[2]); + let before = authored(&conn); + let cmd = command(&conn, b, f); + save(&mut conn, &p, &cmd, 51).unwrap(); + refresh(&conn, &mut p); + assert_eq!(authored(&conn), before); + let review = crate::script_context_scope::tests::target(&conn, &blocks[1]) + .impact + .unwrap(); + assert!(review.needs_review); + assert!( + review + .causes + .iter() + .any(|c| c.reason == ScriptImpactReason::ContextChanged) + ); + let req = crate::script_context_scope::tests::request(&conn, &blocks[1]); + let binding = crate::script_impact_review::capture(&conn, &req.payload).unwrap(); + crate::script_impact_review::record_proposal( + &mut conn, + &req, + binding, + "Labelled synthetic reordered-scene preview".into(), + 52, + ) + .unwrap(); + assert_eq!(authored(&conn), before); + crate::script_impact_review::tests::accept(&mut conn, &req).unwrap(); + assert!( + !crate::script_context_scope::tests::target(&conn, &blocks[1]) + .impact + .unwrap() + .needs_review + ); +} + +fn authored(conn: &Connection) -> String { + let doc = crate::script_store::load_document_projection( + conn, + &ScriptDocumentId::new("script.document.main").unwrap(), + ) + .unwrap() + .unwrap(); + let mut blocks = doc + .segments + .into_iter() + .flat_map(|s| s.blocks) + .collect::>(); + blocks.sort_by_key(|b| b.block.id.clone()); + serde_json::to_string(&blocks).unwrap() +} +#[test] +fn inserted_then_deleted_sibling_invalidates_original_read_without_writing() { + let (mut conn, mut p, a, b) = crate::timeline_script_placement::tests::fixture(); + refresh(&conn, &mut p); + let a = NodeId(uuid::Uuid::parse_str(a.source_node_id.as_ref().unwrap()).unwrap()); + let b = NodeId(uuid::Uuid::parse_str(b.source_node_id.as_ref().unwrap()).unwrap()); + let stale = command(&conn, a, b); + let created = NodeId::new(); + crate::timeline_command_history::record_create_timeline_node_history( + &mut conn, + &p, + &CommandEnvelope::new(CreateTimelineNodeCommand { + node_id: created, + parent_id: p.timeline.node(a).unwrap().parent_id, + level: eidetic_core::timeline::node::StoryLevel::Scene, + name: "Temporary neighbor".into(), + start_ms: 2500, + end_ms: 3000, + beat_type: None, + }), + 55, + ) + .unwrap(); + refresh(&conn, &mut p); + crate::timeline_node_delete_history::record_delete_timeline_node_history( + &mut conn, + &p, + &CommandEnvelope::new(DeleteTimelineNodeCommand { node_id: created }), + 56, + ) + .unwrap(); + refresh(&conn, &mut p); + let now = capture(&conn, a).unwrap(); + assert_eq!(now.siblings, stale.payload.expected.siblings); + assert_ne!( + now.membership_revision_event_id, + stale.payload.expected.membership_revision_event_id + ); + let before = snapshot(&conn); + assert!(save(&mut conn, &p, &stale, 57).is_err()); + assert_eq!(snapshot(&conn), before); +} +#[test] +fn nested_source_bound_segments_translate_and_pending_acceptance_refuses_reorder_aba() { + let (mut conn, mut p, blocks) = crate::script_context_scope::tests::fixture(); + refresh(&conn, &mut p); + let b = crate::script_context_scope::tests::node(&blocks[1]); + let f = crate::script_context_scope::tests::node(&blocks[2]); + let child = NodeId::new(); + crate::timeline_command_history::record_create_timeline_node_history( + &mut conn, + &p, + &CommandEnvelope::new(CreateTimelineNodeCommand { + node_id: child, + parent_id: Some(b), + level: eidetic_core::timeline::node::StoryLevel::Beat, + name: "Nested authored beat".into(), + start_ms: 4100, + end_ms: 4500, + beat_type: None, + }), + 50, + ) + .unwrap(); + refresh(&conn, &mut p); + let mut block = blocks[1].clone(); + block.source_node_id = Some(child.0.to_string()); + block.segment_id = ScriptSegmentId::new("segment.child").unwrap(); + block.block_id = ScriptBlockId::new("block.child").unwrap(); + block.segment_start_ms = 4100; + block.segment_end_ms = 4500; + block.text = " Nested author text 雨\n\n".into(); + crate::script_document_command::apply_set_script_block( + &mut conn, + &CommandEnvelope::new(block), + 51, + ) + .unwrap(); + let before = authored(&conn); + let cmd = command(&conn, b, f); + save(&mut conn, &p, &cmd, 52).unwrap(); + refresh(&conn, &mut p); + assert_eq!(authored(&conn), before); + assert_eq!(p.timeline.node(child).unwrap().time_range.start_ms, 2100); + let (start, end): (i64, i64) = conn + .query_row( + "SELECT start_ms,end_ms FROM script_segments WHERE id='segment.child'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!((start, end), (2100, 2500)); + let req = crate::script_context_scope::tests::request(&conn, &blocks[1]); + let binding = crate::script_impact_review::capture(&conn, &req.payload).unwrap(); + crate::script_impact_review::record_proposal( + &mut conn, + &req, + binding, + "Labelled synthetic pending reorder preview".into(), + 53, + ) + .unwrap(); + for time in [54, 55] { + let reverse = command(&conn, b, f); + save(&mut conn, &p, &reverse, time).unwrap(); + refresh(&conn, &mut p); + } + let before = snapshot(&conn); + assert!(crate::script_impact_review::tests::accept(&mut conn, &req).is_err()); + assert_eq!(snapshot(&conn), before); +} +#[test] +fn pending_generation_refuses_reordered_then_restored_source_without_writes() { + let (mut conn, mut p, generation) = crate::script_generation_target::tests::fixture(); + refresh(&conn, &mut p); + let b = generation.payload.target_binding.as_ref().unwrap().node_id; + let f = NodeId(uuid::Uuid::from_u128(6)); + for time in [65, 66] { + let cmd = command(&conn, b, f); + save(&mut conn, &p, &cmd, time).unwrap(); + refresh(&conn, &mut p); + } + let before = snapshot(&conn); + assert!( + crate::script_document_command::apply_generated_script_block(&mut conn, &generation, 67) + .is_err() + ); + assert_eq!(snapshot(&conn), before); +} +#[test] +fn original_receipt_replays_after_selected_clip_deletion() { + let (mut conn, mut p, a, b) = crate::timeline_script_placement::tests::fixture(); + refresh(&conn, &mut p); + let a = NodeId(uuid::Uuid::parse_str(a.source_node_id.as_ref().unwrap()).unwrap()); + let b = NodeId(uuid::Uuid::parse_str(b.source_node_id.as_ref().unwrap()).unwrap()); + let cmd = command(&conn, a, b); + save(&mut conn, &p, &cmd, 65).unwrap(); + let receipt = command_receipt(&conn, &cmd).unwrap(); + refresh(&conn, &mut p); + crate::timeline_node_delete_history::record_delete_timeline_node_history( + &mut conn, + &p, + &CommandEnvelope::new(DeleteTimelineNodeCommand { node_id: a }), + 66, + ) + .unwrap(); + refresh(&conn, &mut p); + let before = snapshot(&conn); + assert_eq!( + save(&mut conn, &p, &cmd, 67).unwrap(), + RecordChangeOutcome::AlreadyRecorded + ); + assert_eq!(command_receipt(&conn, &cmd).unwrap(), receipt); + assert_eq!(snapshot(&conn), before); +} diff --git a/crates/server/src/timeline_sibling_reorder_history.rs b/crates/server/src/timeline_sibling_reorder_history.rs new file mode 100644 index 00000000..d752ad9b --- /dev/null +++ b/crates/server/src/timeline_sibling_reorder_history.rs @@ -0,0 +1,102 @@ +//! Atomic two-subtree authoring and source-bound screenplay placement. +use crate::{ + history_store::{self, HistoryStoreError, RecordChangeOutcome}, + timeline_command::TimelineCommandError, + timeline_node_store, +}; +use eidetic_core::{Project, contracts::*}; +use rusqlite::Connection; +pub(crate) fn record_reorder_timeline_sibling_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.sibling_reorder")? + { + return Ok(outcome); + } + if command.payload.expected.node_id != command.payload.node_id { + return Err(HistoryStoreError::InvalidValue( + "Sibling snapshot belongs to another clip".into(), + ) + .into()); + } + let mut next = project.timeline.clone(); + next.reorder_adjacent_nodes(command.payload.node_id, command.payload.neighbor_id)?; + let event = ChangeEvent::new( + command.id, + ChangeEventKind::UserEdit, + format!( + "reorder {} with {}", + project.timeline.node(command.payload.node_id)?.name, + project.timeline.node(command.payload.neighbor_id)?.name + ), + ) + .with_created_at_ms(created_at_ms); + let mut revisions = Vec::new(); + let mut ranges = Vec::new(); + for old in &project.timeline.nodes { + let new = next.node(old.id)?; + if old.time_range != new.time_range || old.sort_order != new.sort_order { + let mut revision = ObjectRevision::new( + ObjectKind::TimelineNode, + old.id.0.to_string(), + event.id, + RevisionOperation::Update, + ); + if old.time_range != new.time_range { + for (field, old_value, new_value) in [ + ("start_ms", old.time_range.start_ms, new.time_range.start_ms), + ("end_ms", old.time_range.end_ms, new.time_range.end_ms), + ] { + revision = revision.with_field(FieldDelta::new( + field, + Some(FieldValue::Integer(crate::script_store_codec::to_i64( + old_value, field, + )?)), + Some(FieldValue::Integer(crate::script_store_codec::to_i64( + new_value, field, + )?)), + )); + } + ranges.push((new.id, new.time_range)); + } + if old.sort_order != new.sort_order { + revision = revision.with_field(FieldDelta::new( + "sort_order", + Some(FieldValue::Integer(i64::from(old.sort_order))), + Some(FieldValue::Integer(i64::from(new.sort_order))), + )); + } + revisions.push(revision); + } + } + crate::script_store::create_schema(conn)?; + Ok(history_store::record_change_with( + conn, + command, + "timeline.sibling_reorder", + &event, + &revisions, + |tx| { + crate::timeline_command_guard::validate_current_timeline(tx, &project.timeline)?; + if crate::timeline_sibling_order::capture_excluding( + tx, + command.payload.node_id, + Some(event.id), + )? != command.payload.expected + { + return Err(HistoryStoreError::InvalidValue("Sibling placement changed; discard the reorder draft and read current siblings before applying".into())); + } + timeline_node_store::upsert_nodes_in_transaction(tx, &next.nodes)?; + crate::timeline_script_placement::sync_in_transaction( + tx, + &ranges, + event.id, + revisions.len(), + ) + }, + )?) +} diff --git a/docs/dependency-governance.md b/docs/dependency-governance.md index a685c2b1..2bdcfbb3 100644 --- a/docs/dependency-governance.md +++ b/docs/dependency-governance.md @@ -50,7 +50,7 @@ EIDETIC_FETCH_PUMAS=1 scripts/prepare-pumas-dependency.sh Default source contract: - Repository: `https://github.com/MrScripty/Pumas-Library.git` -- Revision: `8444b50df28c3e2bd8db58fb3645fa4dd8664b27` +- Revision: `a94fd92021f27fdeedb6e2de6e01c41c250ef576` - Required package: `pumas-library` - Required local path: `../../ai-systems/Pumas-Library/rust/crates/pumas-core` @@ -68,9 +68,19 @@ private mirror or an already provisioned checkout. - Pumas remains a path dependency until the sibling repository has an agreed publication or git-dependency contract. CI must provision the exact expected checkout before running Cargo. -- npm currently reports low-severity `cookie <0.7.0` advisories through - `@sveltejs/kit`. The compatible `npm audit fix` path has already been applied; - npm's remaining `--force` suggestion would install `@sveltejs/kit@0.0.30`, so - it is rejected as a breaking downgrade. The dependency gate blocks - high/critical npm advisories and leaves this low advisory tracked here until a - compatible SvelteKit release resolves it. +- `cookie 0.6.0` remains a low-severity finding through SvelteKit's declared + `^0.6.0` dependency. The static desktop deployment has no Node HTTP cookie + serializer or server load/form endpoints; application input does not supply + cookie names, paths or domains. Do not override the producer's dependency + contract to make the audit count disappear. The frontend dependency owner must + select a supported parent update before adding server-side cookie handling or + accepting untrusted cookie metadata. See the exact finding and follow-up in + [PR29's dependency audit](reports/pr29-dependency-audit.md). + +The 2026-10-14 authoring integration carries the exact reviewed frontend repair +from PR29 commit `be03aa1f2b7e6e19da632fac3b2b7eb89ec12c73`, without importing +its Pumas/model lifecycle changes. The security-relevant CI, package manifest, +lockfile and historical audit report match that source. The repair uses compatible +patches, SvelteKit 2.70.3 and coordinated Vitest/coverage 4.1.11, retaining Node +24.12.0 and Vite 6.x. The audit report's previous execution evidence belongs to +PR29; each new authoring integration must run its own checks and source CI. diff --git a/docs/plans/agent-story-workflows/bible-fact-text-custody.md b/docs/plans/agent-story-workflows/bible-fact-text-custody.md new file mode 100644 index 00000000..84f0b0cc --- /dev/null +++ b/docs/plans/agent-story-workflows/bible-fact-text-custody.md @@ -0,0 +1,31 @@ +# Exact authored Bible freeform text + +Separate bounded successor of accepted Notes commit +`c703ebb7a0016554b702124a794a4654968b42cd`, tree +`ab5d64837792dc9379370a927a32486a6cc92f39`. Notes/PR28 and PR27 stay unchanged. + +The authoring roadmap and manual-edit Bible reconciliation require preservation +of authored material and canonical before/after fact provenance. The existing +Bible Save/preview/accept workflow and graph dependency memory already work. +This slice corrects an input loss rather than introducing another fact writer. + +Before implementation, the actual client-compiled `createBibleGraphFieldDrafts` +received ` Mara reveals the witness — 雨.\n\n ` but called its existing Save callback +with `Mara reveals the witness — 雨.`. A regression against the unchanged owner +failed with those exact actual/expected values; eleven existing client tests passed. +Its request construction called `submitted.trim()` even though +`BibleGraphPartFields` forwards exact text and the canonical field writer retains +`FieldValue::Text` verbatim. The UI retired the draft after acknowledgement of the +trimmed value, silently discarding author input. Baseline evidence is retained +outside Git in `bible-fact-text-custody-evidence/exact-fact-baseline.log`. + +Keep the submitted freeform text exactly as entered. Existing empty input still +maps to `null` and clears the field. Whitespace-only authored input is a text value, +not an empty clear. Existing typed fields, canonical writer/provenance, validation, +conflict handling, pending-save custody and explicit screenplay acceptance are +unchanged. No schema, dependency, provider, recovery or new command is added. + +Client qualification includes leading/trailing spaces, newline/Unicode text, +whitespace-only text and explicit empty clear. Qualification/independent review +and exact source are recorded in the separate external handoff. No hosted or +real-model quality claim is implied. diff --git a/docs/plans/agent-story-workflows/execution-ledger.md b/docs/plans/agent-story-workflows/execution-ledger.md index 4d891702..bfc6eeee 100644 --- a/docs/plans/agent-story-workflows/execution-ledger.md +++ b/docs/plans/agent-story-workflows/execution-ledger.md @@ -2082,3 +2082,11 @@ 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. + +### Timeline sibling reorder milestone + +- Base: accepted arc assignment `1f45e152fe68b9dd34d8058a8c1064ba6de3437f`, preserving Notes, exact Bible text and missing-screenplay draft recovery checkpoints. +- Evidence before implementation: single-node drag/exact placement existed, but no atomic neighboring move authored both subtrees and hierarchy order. Child/hierarchy ordering uses `sort_order` before placement; screenplay context is chronological. Existing scene-order memory was already working and reused. +- Adds explicit adjacent **Move earlier/later → named pair preview → Apply reorder**, canonical two-subtree checked translation/order revisions, atomic source-bound screenplay placement, backend sibling/membership ABA reads and original immutable replay receipts. Existing continuity review/targeted acceptance handles downstream material; manual text and draft custody remain intact. Structural moves preserve content locks under the documented M4 policy. +- Local rendered qualification: real AppShell/Bible/timeline/screenplay plus public SQLite services, six final screenshots, labelled synthetic provider. Does not establish native runtime or real-model quality. Source/module receipts are post-run; earlier probes are explicitly retained separately. Independent final review and exact source/test/evidence seals are in the parent handoff. +- See `docs/reports/timeline-sibling-reorder-qualification.md`; external evidence `/workspace/scratch/timeline-sibling-reorder-evidence`. No paid CI, PR/merge, ONNX403 bypass or Pumas guard weakening. Split-containment policy remains open; this milestone does not complete M4 generally. diff --git a/docs/plans/agent-story-workflows/missing-screenplay-draft-recovery.md b/docs/plans/agent-story-workflows/missing-screenplay-draft-recovery.md new file mode 100644 index 00000000..7c0f17e6 --- /dev/null +++ b/docs/plans/agent-story-workflows/missing-screenplay-draft-recovery.md @@ -0,0 +1,62 @@ +# Missing saved-block draft recovery + +Base: independently accepted Notes c703ebb and Bible exact-text 9998e15. + +Before implementation, the actual ScriptPanel rendering regression retains exact +Unicode/newline manual text and its original revision in the session edit owner, +then reads a canonical document without that block. The panel renders no draft. +Only orphaned removal requests were displayed; canonical disappearance made an +active edit and its unknown-acknowledgment retry inaccessible. The preserved +baseline fails its rendered-text assertion. This is a concrete authoring loss of +access, separate from project switching and the accepted block-type operation. + +Reuse the same per-block session edit owner. Surface unavailable drafts without +claiming a deletion from a pending/failed read; preserve original block/revision +identity. Read/check and immutable original retry remain under existing guards. +Explicit discard acts only on this draft. Explicit copy enters the existing +single composer for the visibly selected canonical clip, preserving original +text and kind and leaving the original draft intact. A busy/active composer or +uncertain original save refuses copy; no automatic recreate, retarget or commit. +Cancel of the copy preserves the original. Ordinary Save uses existing canonical +CreateScriptBlock, UserEdited provenance, history and downstream review. A copy +is a new authored block, not restoration of old identity or consumed lineage. + +Qualification covers missing-block panel/remount, independent drafts, same-ID +return, reads failing or returning old versions, pending/lost-ack exact retry, +repeated copy/save/cancel and actual public create/remove storage. Preserve +saved unrelated material and require explicit downstream proposal acceptance. +No backend API/schema, dependency, alias mapping, new canonical store or native/ +real-model qualification is implied. No hosted jobs or main merge. + +## Local qualification + +608 UI tests in 105 files pass; independent preliminary review reran 22 focused +tests. Svelte check reports zero errors/warnings. Final rendered Chromium uses +actual AppShell, Bible inspector, screenplay and timeline with a temporary IPC +adapter to real public SQLite services. It removes a saved block externally via +its ordinary canonical removal command while its exact manual draft is open. +The draft remains visible through panel navigation, explicit copy/cancel and +copy Save with a dropped post-commit acknowledgment. Exact retry creates one +UserEdited block, retaining the original draft and unrelated saved blocks. +The public agent-context preview contains exact copied text. Existing targeted +preview preserves all saved blocks; explicit acceptance changes only its bound +generated target text/revision. Only explicit discard clears the original draft. + +The final walkthrough has three labelled synthetic HTTP replies: initial +generation, recap and targeted preview. Nine replies total are retained across +two earlier rehearsals plus the final isolated project. The final responder +distinguishes generation/preview by actual prompt, rather than a global count; +the final assertions require distinct target text/revision and complete equality +of every other block. Earlier layout rehearsals are retained separately. + +These are instrumented browser/public-service checks, not native/release or +real-model narrative qualification. Native renderer status is unavailable in +the test adapter and visibly reports that limitation. Runtime exception checks +pass; they do not hide missing-adapter-route transcript errors. No reopen after +downstream acceptance is claimed. Stable served module bytes are retained after +the run, not capture-time network attestation. Temporary UI route, Vite config +and Rust adapter example are removed/restored before the product commit. + +Evidence is outside Git under +`/workspace/scratch/missing-screenplay-draft-recovery-evidence`. Parent owns PRs, +merge gates, hosted native qualification and Library delivery. diff --git a/docs/plans/agent-story-workflows/plan.md b/docs/plans/agent-story-workflows/plan.md index f9772a86..30182ae4 100644 --- a/docs/plans/agent-story-workflows/plan.md +++ b/docs/plans/agent-story-workflows/plan.md @@ -35,7 +35,9 @@ Bible and screenplay review flows. A source regression proves public authored Notes changed after generation has no review cause. The bounded successor reuses the generation target receipt and owned Notes field history for original/current review and explicit selected-block acceptance. See `timeline-notes-review.md`. -Ancestor/sibling prose, arc membership and project switching remain separate. +Ancestor/sibling prose and project switching remain separate. The independent +manual timeline arc-assignment successor adds canonical membership authoring and +original/current targeted screenplay review; see `timeline-arc-assignment.md`. **Current phase:** Authored Bible fact propagation is merged in main `302851dbb5bf67cda922b4d79f70623e444891dc`; scene-order continuity is qualified at frozen predecessor `441c2a29af6199b53b92f718df43d0ca916b1ce5`. @@ -1187,3 +1189,26 @@ 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. + +### Exact authored Bible freeform text (separate successor) + +After independently accepted Notes c703ebb, the actual Bible field draft owner +reproduction proves that `submitted.trim()` silently removes authored whitespace +before canonical Save. See [bible-fact-text-custody.md](bible-fact-text-custody.md). +Reuse the current field writer and owned before/after graph memory, preserving +submitted freeform text exactly while retaining explicit empty clear. Existing +Bible proposals and screenplay review/acceptance are not duplicated. No new +schema, embedding, project-switch or hosted spending scope. + +### Missing saved-block draft recovery (separate successor) + +Accepted Notes c703ebb and Bible exact-text 9998e15 remain preserved. A rendered +ScriptPanel reproduction proves an active draft becomes inaccessible when its +canonical block disappears: only orphaned removal requests were displayed. +Reuse the existing per-block edit owner and single creation composer for visible +retention, immutable original retry, explicit discard and explicit copy into a +new selected-clip draft. Ordinary Save retains canonical UserEdited provenance, +history and downstream review; copying neither writes nor retires the original. +See [missing-screenplay-draft-recovery.md](missing-screenplay-draft-recovery.md). +No project-switch, block-type duplication, new store/schema, Pumas identity +mapping, hosted job or main merge scope. diff --git a/docs/plans/agent-story-workflows/timeline-arc-assignment.md b/docs/plans/agent-story-workflows/timeline-arc-assignment.md new file mode 100644 index 00000000..b9dc98ff --- /dev/null +++ b/docs/plans/agent-story-workflows/timeline-arc-assignment.md @@ -0,0 +1,29 @@ +# Manual timeline arc assignment + +## Verified gap + +The accepted base b8227132 preserves Notes, exact Bible text and missing screenplay draft custody. It descends from verified PR9 successor25b860d12fe37a3538a40616cc02ab4a81370863 (tree d22b036891aed7eb66fc94059aa1b5c1d9aab85a). Existing core tag/untag methods, SQLite node_arcs and rendered clip colors have no manual selected-clip assignment command or UI. Move/resize, placement, blade split and title/Notes already work. The roadmap explicitly leaves arc membership separate. + +This operation assigns story arcs to a clip on its current story-level track. It does not change hierarchy or move the clip to another story level. + +## Canonical operation and review + +The selected editor shows an arc checklist with explicit Save and read/discard controls. Drafts are retained per clip through navigation/panel disposal in the same project session. Unavailable assigned IDs remain visible. Fresh saves respect clip locks; uncertain original saves can still replay after a later lock. + +SetTimelineNodeArcsCommand validates the exact canonical assigned-ID set and its owned arc_ids field clock inside the existing history transaction. It validates live requested arcs, rejects duplicate/no-op/stale requests, writes existing node_arcs, and records a sparse TimelineNode arc_ids revision. Replays return the original command's owned receipt even after a newer assignment. There is no second membership database or canonical frontend state. + +Generation captures the assigned-ID read in its existing target receipt, alongside canonical tagged arc fields. The existing graph receives a TimelineNode membership dependency. Known baseline/empty membership remains distinguishable from missing legacy receipts. Impact uses the actual recorded generation or accepted proposal receipt, so first assignment, last removal and edit/restore ABA are reviewable. Unrelated clips and presentation changes do not invalidate this membership read. + +Targeted proposal bindings retain original/current membership, original/current arc fields and exact screenplay input revisions. Prompt and UI show original/current names/IDs, including current '(no arcs)'. Historical removed arc names are labelled historical evidence; removed arc descriptions are excluded from current guidance. Withdrawal of live consumed arc context is allowed only when known consumed membership and a validated current owned membership transition prove the removal. Missing legacy consumption and corrupted unlogged tag changes stay refused. + +Saving and previewing preserve saved screenplay and manual drafts. Existing explicit Accept replaces only the selected generated block, validates current binding under the writer and retains membership custody for the accepted successor. Stale preview/acceptance and locks preserve canonical material. + +## Verification and limits + +Nine focused backend regressions cover first/last membership, accepted successor, exact original replay, atomic duplicate/missing/no-op refusals, stale save, pending generation/preview/accept ABA, legacy unknown, unrelated clips, lock-after-lost-ack recovery, forged ownership, corrupted tag fixture and historical field ordering. The provider callback test checks last-removal prompt evidence. Thirteen UI tests cover explicit intent, arrays/receipt custody, session ownership, conflicts and rendered evidence. + +The local rendered run uses actual AppShell, Bible inspectors, selected clip editor, screenplay panels and timeline, with normal public SQLite services through a test IPC adapter. Seven screenshots and raw command/provider transcripts cover unsaved checkbox/navigation custody, uncertain save after lock, conflict retaining intent, pending targeted review, stale acceptance, explicit target-only acceptance and last removal from the accepted successor. Manual saved blocks remain byte-for-byte unchanged; an exact Unicode/whitespace manual screenplay draft survives the entire flow. + +All synthetic responses are labelled. This is instrumented Chromium/public-service qualification, not native runtime or real-model quality qualification. A compiled Linux service adapter and stable post-run source/Vite-module byte pairs are retained; module receipts are not capture-time attestation. No reopen after acceptance was exercised. Hosted native qualification remains for the parent workflow; no paid CI, main merge, external review, credential change or ONNX403 bypass was performed. + +Evidence lives in /workspace/scratch/timeline-arc-assignment-evidence. Parent owns PR/review/merge and Library delivery. Frozen PR27/28 and previous accepted checkpoints remain unchanged. diff --git a/docs/reports/authoring-integration-readiness.md b/docs/reports/authoring-integration-readiness.md new file mode 100644 index 00000000..da24d9a4 --- /dev/null +++ b/docs/reports/authoring-integration-readiness.md @@ -0,0 +1,82 @@ +# Authoring integration: source lineage and acceptance + +## Scope and merge order + +The user authorized preparing and merging useful retained branches. The parent +owns actual merges. These integrations preserve main and PR29 history; PR29 +`be03aa1f2b7e6e19da632fac3b2b7eb89ec12c73` is not modified or pushed. +The canonical governing plan remains +`docs/plans/agent-story-workflows/plan.md`; this operation integrates its admitted +authoring slices and does not declare all M4 or trained-model work complete. + +Stage A starts at main `59793c1950916a067f6429db59bbc639fe3c4acf` and merges +`001356c69daca37ad89851d8640722d711c0267d` once, retaining the four source commits: + +| Source | Behavior | +| --- | --- | +| `9998e15d344de63d64e09c13cd827fabbdb76efb` | Preserve exact authored Bible freeform text. | +| `b8227132f8c086f78b7c846eca7e24c4d983a92c` | Retain unavailable screenplay drafts and require explicit copy/discard/retry. | +| `1f45e152fe68b9dd34d8058a8c1064ba6de3437f` | Author clip arc membership through the guarded canonical writer and targeted review. | +| `001356c69daca37ad89851d8640722d711c0267d` | Atomically reorder adjacent sibling subtrees, preserving authored material and original replay custody. | + +The accepted integration merge is `ed6b6fe7`; its parents retain both complete +histories. Main's architecture, native UI and canonical owners remain intact. + +Stage B will stack saved-block type product +`57b0d2ec4562017513f03d38698d93a17f87f43e` on Stage A. The historical qualifiers +`ee167a4e39f360e6e6191ed864eebb7874931e7e` and documentation seal +`3e22b31c41a6eb965dcfdf2b5bb68ad9c4a64f49` remain intact. Their frozen source +workflows are not reintroduced into current production or represented as current +acceptance. The staged PR targets Stage A so its review delta is disjoint. + +## Existing security repair + +Main's frontend lockfile retains the advisories already repaired and independently +reviewed on PR29. Stage A carries the exact functional security repair from +`be03aa1f2b7e6e19da632fac3b2b7eb89ec12c73`: CI audit/build gates, package manifest, +lockfile and historical dependency-audit report. Those four files are copied +byte-for-byte; the dependency governance exception is reconciled with main's +actual `a94fd92021f27fdeedb6e2de6e01c41c250ef576` Pumas setup pin. This does not +import PR29's Pumas 0.8 model lifecycle or infer its trained-model gate passed. +The remaining low cookie finding retains the explicit supported-parent follow-up. + +## Current verification boundary + +Current tests, independent review, exact pushed heads and hosted CI are recorded +in the PRs and this integration's completion record. Historical branch test/native +reports retain their original source identities. Current verification must cover +exact text, missing draft custody, replay and ABA, clip membership, sibling +geometry/order and consumed block-type lineage on the composed source. + +The local executor has Rust 1.92.0, Node 24.12.0 and Chromium. Its native +glib/GTK/WebKit development libraries are absent. Hosted Linux/Windows workspace +CI supplies the full compilation/Clippy/test gate; local UI/public-service +qualification does not claim native Tauri/Bevy interaction or trained inference. +Synthetic test/provider responses remain explicitly synthetic. No trained-model +access, credentials, release or denial bypass belongs to this integration. + +Initial exact-head CI run 102 passed Linux full-workspace checks (986 tests), +frontend (636 tests) and traceability, but Windows failed the existing injected +renderer panic test. Its 200 ms receive could legitimately time out before the +panic hook/unwind and OS scheduling completed. The panic case now uses a bounded +five-second completion-channel wait with the same non-running/Panicked assertions. +Production stop behavior and the other 200 ms controlled-close tests are unchanged. +Independent review accepts this scoped synchronization repair; fresh hosted +Linux/Windows checks on the forward repair remain required. No failed run is +represented as passing and no test/discovery filter is weakened. + +PR29 compatibility is verified only in a disposable integration tree, resolving +additive documentation conflicts while preserving its producer pin, inference +lifecycle and reference prompt/retrieval surfaces. That tree is never pushed to +PR29. Its original remote SHA remains a protected postcondition. + +## Retained resources and retirement + +All useful source branches remain until parent merge acceptance. No history is +rewritten. Unique obsolete history needs a complete, verified durable recovery +bundle before any retirement; patch or ancestry labels alone are insufficient. +Stage worktrees are retained for exact-head parent review, owned by this delegated +integration, synchronized only by reviewed forward commits, and retired after +parent merge/rejection with their exact heads reachable from retained remote refs. +The isolated producer checkout exists only to test main's unchanged exact pin; +the user's existing Pumas checkout remains untouched. diff --git a/docs/reports/pr29-dependency-audit.md b/docs/reports/pr29-dependency-audit.md new file mode 100644 index 00000000..c507f921 --- /dev/null +++ b/docs/reports/pr29-dependency-audit.md @@ -0,0 +1,188 @@ +# PR29 dependency audit disposition + +## Identity and scope + +Reviewed on 2026-10-14 against PR29 head `1148fb0e44efc25420e1b71494eef6d7cae99840`. +Baseline `ui/package-lock.json` blob: `de5b74ee43de2d7f0c3b8fe46ce67eabe7e1f73f`; +SHA256: `71205506e0eae5f68915ce93faa124a6a46a911ab35a0ae1a0ad1afd6837700b`. +Repaired lockfile SHA256: +`99dfbea2e4f52337273b69856f59a4a5c1a0d8b99bb461e94ced87116927a657`. + +The baseline npm snapshot reported 14 high and 3 critical package rows, backed by +19 unique high/critical advisories. Inherited parent severity is distinguished +from an advisory against that parent's own code. The table accounts for all 17 +rows; package versions and parent paths come from the exact lock and `npm explain`. +Upstream GitHub advisories and published npm version/peer/engine metadata were +checked directly. `fixAvailable: false` in the initial npm result did not mean +that upstream lacked a compatible fixed release; the initial readiness report's +inference to that effect is superseded here. + +The write set is the UI manifest/lock, CI's frontend audit/static-build steps, +dependency governance and these readiness reports. No Rust/Pumas dependency or +application-source changes; credentials, model access and the trained-model gate +are unchanged. + +## Deployment and input boundaries + +`ui/svelte.config.js` uses `adapter-static`, writing only `dist/ui` for Tauri's +`frontendDist` in `src-tauri/tauri.conf.json`. Production runs no Node/SvelteKit +HTTP server. There are no application server loads, remote functions, cookie +setters or CSS/YAML/glob processing endpoints. Project/reference/model data is +handled by the existing Tauri/Rust commands, not those build-tool parsers. + +This does not make every `devDependencies` entry non-runtime: SvelteKit's client +source imports devalue and the framework can serialize navigation/server data. +Devalue is classified as runtime-capable and patched. No current hostile +application path into its vulnerable Buffer/async/uneval operations was found; +this is not a general claim that devalue is absent from every emitted bundle. + +Vite/SvelteKit also run in development. Kit 2.61.1's normal dev GET path calls +`server.respond` -> `is_action_json_request` -> `negotiate(Accept)` before checking +the method, making the Accept-header advisory reachable without remote forms. +The same-major Kit update below therefore fixes an actual available path. + +## All baseline high/critical rows + +| npm row / baseline version | Severity | Consumer and attack prerequisites | Repair | +| --- | --- | --- | --- | +| `@eslint/config-array 0.21.2` | High, inherited | Lint configuration glob patterns -> minimatch -> brace expansion; no project-text input. | Parent unchanged; patched brace dependency. | +| `@eslint/eslintrc 3.3.5` | High, inherited | Legacy lint YAML config and glob parsing, requiring malicious tool configuration. | Parent unchanged; YAML/brace patches. | +| `@tailwindcss/node 4.1.18` | High, inherited | CSS build source-map offsets through source-map-js; not runtime text processing. | Parent unchanged; source-map-js 1.2.2. | +| `@tailwindcss/vite 4.1.18` | High, inherited | Tailwind build dependency plus Vite dev-server paths. | Parent unchanged; source-map-js and Vite patches. | +| `@vitest/coverage-v8 3.2.4` | Critical, inherited | Test/coverage host via Vitest and magicast. | Coordinated 4.1.11 family; no Tinypool. | +| `brace-expansion 1.1.15 / 2.1.1 / 5.0.6` | High | ESLint/minimatch, TypeScript lint parser and coverage glob/test-exclude; attacker-controlled expansion patterns can exhaust CPU/stack/memory. | 1.1.21 and 5.0.12; obsolete coverage 2.x/5.x copies removed. | +| `devalue 5.8.1` | High | Runtime-capable framework dependency. Buffer serialization can expose backing storage; special uneval/async inputs can exhaust resources or reject unhandled promises. No current SSR Buffer/remote-input route, but retain no exemption. | 5.9.4. | +| `eslint 9.39.4` | High, inherited | Lint host through config-array/eslintrc/minimatch/YAML; development/CI, not shipped code. | Parent unchanged; patched affected dependencies, including humanfs 0.16.8. | +| `js-yaml 4.1.1` | High | ESLint legacy config parsing of attacker-controlled merge/omap YAML. | 4.3.2. | +| `magicast 0.3.5` | High, inherited | Coverage/config AST/source maps, not application reference documents. | Coverage-owned 0.5.5, with patched source-map-js. | +| `minimatch 3.1.5` | High, inherited | Lint glob selection, via brace expansion. | Parent unchanged; brace-expansion 1.1.21. | +| `nanoid 3.3.12` | High | PostCSS's generated input label calls `nanoid/non-secure` with constant positive size 6; app input cannot choose the negative/zero sizes in the advisories. | Compatible 3.3.20 despite absent current hostile-size path. | +| `postcss 8.5.15` | High | Node build/lint CSS and previous source-map loading; disclosure requires attacker CSS/map paths plus an observable generated result. No runtime CSS compilation endpoint. | 8.5.30 and patched nanoid/source-map-js; selector-parser 7.1.6 closes the related lower-severity lint chain. | +| `source-map-js 1.2.1` | High | Tailwind/PostCSS/magicast processing of malicious indexed source-map section offsets in the tool host. | 1.2.2. | +| `tinypool 1.1.1` | Critical | Vitest workers; an existing `Object.prototype` pollution primitive can materialize inherited worker/run options and obtain test-host execution. Not a desktop runtime worker. | Removed through supported Vitest 4 worker implementation; no forced 2.x override. | +| `vite 6.4.2` | High | Explicitly exposed dev server on Windows/NTFS with sensitive files in allowed directories; alternate paths bypass denial. Default launcher is loopback, but patch the supported Windows tool. | Same-line 6.4.4. | +| `vitest 3.2.4` | Critical | UI/browser/API server exposed or Windows UI mode; repository uses `vitest run` and no UI/browser/API configuration. Its worker dependency still needs repair independently. | 4.1.11 with matching coverage plugin. | + +## Upstream advisory verification + +These are the direct high/critical advisories in the baseline. Ranges/fixed +versions below describe the consumed major lines; selected versions exceed all +listed bounds. Linked upstream pages own complete multi-major ranges. + +| Advisory | Affected consumed line / first fixed release | +| --- | --- | +| [GHSA-3jxr-9vmj-r5cp](https://github.com/advisories/GHSA-3jxr-9vmj-r5cp) | Brace: <1.1.16 / <2.1.2 / 3.x–<5.0.7; fixes 1.1.16, 2.1.2, 5.0.7. | +| [GHSA-mh99-v99m-4gvg](https://github.com/advisories/GHSA-mh99-v99m-4gvg) | Brace: <1.1.17 / 2.x<2.1.3 / 4.x–<5.0.8; fixes 1.1.17, 2.1.3, 5.0.8. | +| [GHSA-rgw5-rvv9-x895](https://github.com/advisories/GHSA-rgw5-rvv9-x895) | Brace: <1.1.18 / 2.x<2.1.4 / 4.x–<5.0.9; fixes 1.1.18, 2.1.4, 5.0.9. | +| [GHSA-6j4f-fj2g-mc7p](https://github.com/advisories/GHSA-6j4f-fj2g-mc7p) | Brace: <1.1.19 / 2.x<2.1.5 / 4.x–<5.0.10; fixes 1.1.19, 2.1.5, 5.0.10. | +| [GHSA-qhr7-859c-m2p7](https://github.com/advisories/GHSA-qhr7-859c-m2p7) | Brace: <1.1.20 / 2.x<2.1.6 / 4.x–<5.0.11; fixes 1.1.20, 2.1.6, 5.0.11. | +| [GHSA-j22f-vq7h-c4qm](https://github.com/advisories/GHSA-j22f-vq7h-c4qm) | Devalue >=5.1.0–5.9.2; fixed 5.9.3. | +| [GHSA-mcm9-63f2-9j32](https://github.com/advisories/GHSA-mcm9-63f2-9j32) | Devalue <=5.9.2; fixed 5.9.3. | +| [GHSA-x5rw-q4pp-hg5g](https://github.com/advisories/GHSA-x5rw-q4pp-hg5g) | Devalue >=5.8.0–5.9.2; fixed 5.9.3. | +| [GHSA-52cp-r559-cp3m](https://github.com/advisories/GHSA-52cp-r559-cp3m) | YAML 4.x<4.3.0; fixed 4.3.0. | +| [GHSA-5p4m-2wfm-xmqj](https://github.com/advisories/GHSA-5p4m-2wfm-xmqj) | YAML 4.x<4.3.1; fixed 4.3.1. | +| [GHSA-2883-xcg3-v3hh](https://github.com/advisories/GHSA-2883-xcg3-v3hh) | YAML 4.x<4.3.2; fixed 4.3.2. | +| [GHSA-28wg-ghj8-5hjv](https://github.com/advisories/GHSA-28wg-ghj8-5hjv) | Nanoid <3.3.16; fixed 3.3.16. | +| [GHSA-2v37-7h3g-55p8](https://github.com/advisories/GHSA-2v37-7h3g-55p8) | Nanoid <3.3.18; fixed 3.3.18. | +| [GHSA-r28c-9q8g-f849](https://github.com/advisories/GHSA-r28c-9q8g-f849) | PostCSS <=8.5.17; fixed 8.5.18. Selected 8.5.30 also fixes the later moderate map-loading advisory. | +| [GHSA-68fv-2mgg-jv7q](https://github.com/advisories/GHSA-68fv-2mgg-jv7q) | source-map-js 1.x<1.2.2; fixed 1.2.2. | +| [GHSA-5gmw-xhrv-c9v3](https://github.com/advisories/GHSA-5gmw-xhrv-c9v3) | Tinypool <=2.1.0; first fixed 2.1.1, insufficient for the next advisory. No patched 1.x. | +| [GHSA-85c8-ppgw-ccpr](https://github.com/advisories/GHSA-85c8-ppgw-ccpr) | Tinypool <2.1.2; fixed 2.1.2. No patched 1.x; selected graph removes it. | +| [GHSA-fx2h-pf6j-xcff](https://github.com/advisories/GHSA-fx2h-pf6j-xcff) | Vite <=6.4.2; fixed 6.4.3. | +| [GHSA-5xrq-8626-4rwp](https://github.com/advisories/GHSA-5xrq-8626-4rwp) | Current reviewed feed: Vitest <3.2.6 or 4.x<4.1.0; selected 4.1.11. | + +## Version decision and compatibility evidence + +Vitest 3.2.7 still requires Tinypool `^1.1.1`; patching only Vitest 3 cannot +remove that critical finding. A forced Tinypool major would contradict the +parent's supported range. [Vitest 4's migration guide](https://v4.vitest.dev/guide/migration) +documents removal of Tinypool, Vite >=6 and Node >=20. The repo already pins +Node 24.12.0 and Vite 6, and has no custom pools, browser/UI server, removed +workspace/coverage options or custom runner APIs. Vitest and coverage-v8 are +updated together to 4.1.11. Existing assertions and discovery remain unchanged. +The current 4.1.11 cohort also fixes the moderate mocker path issue in 3.x. + +Other changes stay in their existing families: Vite 6.4.4, Kit 2.70.3, devalue +5.9.4, PostCSS 8.5.30, nanoid 3.3.20, source-map-js 1.2.2, YAML 4.3.2, brace +1.1.21/5.0.12, humanfs/node 0.16.8 and selector-parser 7.1.6. The latter two +are compatible fixes within the already-reviewed ESLint/PostCSS chains. +Supporting Babel/Chai/magicast/worker-tool changes belong to the selected Vitest +cohort. Removed coverage/glob packages and Tinypool account for most lockfile +deletions. No direct Svelte, ESLint, Tailwind, TypeScript or Rust family upgrade, +no alternative registry, no overrides, and no `audit fix --force` are used. + +The additional Kit repair closes [Accept-header negotiation](https://github.com/advisories/GHSA-29g2-3rmr-qm68) +(fixed 2.70.2) and its remote-form issues; supported peers still include this +repo's Svelte 5, plugin 5, Vite 6 and Node 24. The same-family supporting repairs +close [humanfs symlink copies](https://github.com/advisories/GHSA-p498-v437-472g) +and [selector-parser CPU exhaustion](https://github.com/advisories/GHSA-rj75-hqrm-r3gf), +without relying on their absence from application input paths as an exception. + +## Remaining finding and required decision + +The final exact-lock audit reports zero critical, high or moderate findings and +three low package rows: cookie, Kit and adapter-static. There is no remaining +high/critical exception. The remaining direct advisory is +[cookie GHSA-pxg6-pf52-xh8x](https://github.com/advisories/GHSA-pxg6-pf52-xh8x), +low severity, `cookie <0.7.0`; the lock contains 0.6.0 through Kit's `^0.6.0`. +Npm may also report Kit/adapter parent rows for that same advisory. + +The prerequisite is an application passing untrusted cookie **names, paths or +domains** to the Node cookie serializer. No such caller, HTTP production server +or server load/form endpoint exists in this static Tauri application. The build +server output under `.svelte-kit` is not the shipped `dist/ui` payload. Normal +development does not introduce application cookie setters either. + +This is a specific existing low-severity disposition, not blanket acceptance. +The frontend dependency owner must reassess it before introducing SSR/HTTP +deployment, remote forms or application-supplied cookie metadata, and during +the next Kit dependency update. As of this review, the latest Kit 2 release is +2.70.3 and retains cookie 0.6; Kit 3.0.1 uses cookie `^2.0.1`. Parent decision: +schedule a separately reviewed Kit 3 migration or obtain a supported Kit 2 +backport before claiming that this dependency advisory is closed. Forcing +cookie >=0.7 under Kit 2 is not selected without a supported compatibility +contract. The existing high/critical gate is unchanged; no threshold is lowered. + +## Verification and retained gates + +The selected graph is installed with `npm ci` under pinned Node 24.12.0. Full +lint/format/typecheck, all 107 configured files / 608 tests and the static build +pass after the final dependency changes. The final audit has three low inherited/ +direct rows for one cookie advisory, and zero moderate/high/critical findings; +`npm audit --audit-level=high` exits successfully. `npm ls` and the existing +`scripts/check-dependencies.sh` pass, with explicit optional Rust-tool warnings. +No test assertions, discovery filters, +snapshots or application behavior were weakened for the runner migration. +The matched coverage provider also passed a five-test Pumas lifecycle/prompt +smoke with V8 coverage enabled before the final lint-only transitive patches. + +A bounded public-API regression used a synthetic two-byte Node Buffer view over +a 64-byte backing store, with no real process data. Devalue 5.8.1's +`parse(stringify(buffer))` reproduced a 64-byte backing-store disclosure; +5.9.4 preserves only the selected two bytes. Its `uneval` round trip also +preserves exactly those two bytes. These are dependency tests, not model output. + +Reproduce the source verification from `ui`: + +```bash +npm ci +npm audit --audit-level=high +npm ls +npm run lint +npm run format:check +npm run typecheck +npm run test +npm run build +``` + +CI now enforces the existing npm high/critical gate and the actual static build. +The PR record owns the exact pushed head, final independent review and hosted +run. Local full-native testing remains blocked by missing glib/GTK/WebKit; +the existing hosted Linux/Windows Rust checks are retained. Optional local Rust +audit/deny/machete tools remain absent; npm findings are not a claim that those +independent Rust audits were executed. + +PR29 remains draft for parent review. Trained chat/embedding inference remains +unexecuted, with the original access denial and operator acceptance procedure +preserved in [the readiness report](pr29-merge-readiness.md). No model access +bypass, release or PR merge is performed. diff --git a/docs/reports/timeline-sibling-reorder-qualification.md b/docs/reports/timeline-sibling-reorder-qualification.md new file mode 100644 index 00000000..a5736096 --- /dev/null +++ b/docs/reports/timeline-sibling-reorder-qualification.md @@ -0,0 +1,29 @@ +# Atomic timeline sibling reorder + +## Gap and scope + +Based on accepted arc-assignment source `1f45e152fe68b9dd34d8058a8c1064ba6de3437f` (tree `ac1cb02fad05d636d8b4dd4c99a89b8d0cb176a6`). + +The M4 authoring plan calls for shared validated manual/agent timeline mutations. Existing drag/exact placement moves one clip and already supports consumed scene-order memory. There was no atomic adjacent-sibling reorder command/editor. `Timeline::children_of` orders by `(sort_order, start_ms)` while screenplay context follows chronological segment placement. Moving one clip alone does not author both placements or their hierarchy order. This milestone adds that concrete operation; it does not claim scene-order lineage was previously absent or complete the deferred split-containment work. + +## Authored change and review + +The selected-clip editor provides **Move earlier/later**, a named pair preview and **Apply reorder**. Choosing a direction does not write. For the rendered fixture, B: Setup initially occupies 150000–300000 ms and A: Complication 300000–480000 ms. Moving A earlier produces A 150000–330000 and B 330000–480000. Each duration and the original gap/outer span are preserved. + +The core plans both subtrees before mutation, translates descendants by checked exact integer offsets, swaps root `sort_order`, and rejects different parents/levels, nonadjacent clips, overlap, inconsistent hierarchy/chronological adjacency and invalid child containment. Equal order values use the existing chronological tie-break. Content locks retain their AI-regeneration meaning: manual structural reorders preserve locks and remain allowed. + +The server uses existing command/object-revision history, canonical nodes and `timeline_script_placement`. Both roots and all changed descendants receive sparse placement/order revisions; source-bound screenplay segment geometry updates in the same SQLite transaction. Block text, spans, locks, block revisions and unrelated material remain untouched. Existing continuity dependencies identify affected generated scene material and supply the normal targeted preview/explicit acceptance workflow. No parallel reorder state, embedding dependency or automatic screenplay replacement was added. + +The backend-owned sibling read includes all sibling identities, names, placement/order and owned node clocks, plus the parent's structural membership clock. The writer checks that receipt and the full canonical timeline inside its transaction. Insert/delete ABA invalidates the old read. Recorded-command replay precedes current existence/lock checks, and the returned pair receipt is validated against the original stored command and sparse fields rather than reconstructed from current placement. Pending generation, preview and acceptance retain their existing stale/ABA guards. + +The UI keeps per-clip intent in the existing project session, with a privately copied immutable original pair/command for uncertain retry. Changed saved siblings retain draft intent and block stale fresh applies. Successful explicit reads discard the draft; they cannot discard an uncertain command. A confirmed acknowledgement survives asynchronous projection refresh or a later lock clock. Saved screenplay and an exact whitespace/Unicode manual draft survive navigation and downstream review. + +## Validation and qualification boundary + +Final maintained checks pass **127 core / 613 server** tests (zero ignored), **634 UI tests / 110 files**, strict core/server all-target Clippy, Rust/Tauri formatting, ESLint, Svelte diagnostics (0 errors / 0 warnings) and the static production build. New focused cases comprise 4 core, 7 server and 13 UI tests. + +Meaningful unit cases cover unequal durations/gap, nested translation, malformed/overlapping clips, equal hierarchy order, near-maximum safe UI timestamps, content locks, whole-sibling clocks, membership ABA, original retry after later reorder/deletion, immutable transport copies, project-session ownership, pending generation refusal and pending acceptance ABA. The rendered qualification runs actual AppShell/Bible/timeline/screenplay components through a compiled local adapter to public command/projection services and real SQLite. Its six final captures show unwritten pair intent, uncertain-but-committed state, retained conflicting intent, pending targeted review, stale acceptance, and explicit target-only acceptance. + +All provider responses are labelled synthetic fixtures. This is local Chromium/public-service qualification, not native Tauri/Bevy qualification or real-model execution/quality. The test adapter manually refreshes normal projections because it has no native event transport. Raw local source/current Vite module bytes were measured after the final run; they are not capture-time source attestation. Earlier local probe transcripts/captures are retained separately, including the corrected acknowledgement display and harness readiness/projection-refresh issues. No ONNX403 bypass, paid CI or Pumas identity-policy change occurred. + +Exact final source/tree, test totals, independent review, screenshot hashes and supporting evidence are bound in the external handoff/manifest under `/workspace/scratch/timeline-sibling-reorder-evidence`. Parent owns native qualification, PR/review/merge and Library delivery. Deferred split containment, project-switch recovery and optional embeddings remain outside this operation. diff --git a/src-tauri/src/README.md b/src-tauri/src/README.md index 30147e96..1c8d84a6 100644 --- a/src-tauri/src/README.md +++ b/src-tauri/src/README.md @@ -102,3 +102,19 @@ The Bevy timeline bridge continues submitting legacy range commands with no expe The command registry includes `projection_bible_recall` for explicit selected Bible entity inspection; screenplay replacement remains behind existing review and acceptance commands. + +## Authoring command registration + +The invoke registry admits `command_timeline_node_arcs` and +`command_timeline_sibling_reorder` beside existing timeline operations. Their +adapters preserve the server's original read and replay response; current +projection events remain the refresh boundary. This adds no provider, model +bootstrap, native runner or canonical frontend state. + +## Injected window-thread panic verification + +The panic-result test waits on the actual completion channel with a bounded +five-second test budget. It requires a terminal non-running `Panicked` result, +without imposing 200 ms on platform scheduling or the panic hook. Production +stop behavior/timeouts and the controlled completion/close tests stay unchanged. +This injected-runner check supplies lifecycle unit evidence, not native pixels. diff --git a/src-tauri/src/commands/README.md b/src-tauri/src/commands/README.md index a730b10f..2fdf961f 100644 --- a/src-tauri/src/commands/README.md +++ b/src-tauri/src/commands/README.md @@ -106,3 +106,12 @@ The explicit `command_script_fact_proposal_analyze` command delegates one saved `command_timeline_node_name` saves a canonical clip title using the required exact title read receipt and existing timeline command publication. + +## Clip arc and sibling commands + +`command_timeline_node_arcs` and `command_timeline_sibling_reorder` forward typed +command envelopes to existing public server services. Successful new writes +publish normal timeline/selected-editor/script/change-review events after the +canonical transaction. Replays return the stored response without a second +mutation or source-clock reconstruction. Native adapters own neither membership +validation nor a duplicate timeline. diff --git a/src-tauri/src/commands/timeline.rs b/src-tauri/src/commands/timeline.rs index 31a3935e..5b8ae0bb 100644 --- a/src-tauri/src/commands/timeline.rs +++ b/src-tauri/src/commands/timeline.rs @@ -1,7 +1,7 @@ use eidetic_core::contracts::{ CommandEnvelope, DeleteTimelineNodeCommand, DeleteTimelineRelationshipCommand, - SetTimelineNodeLockCommand, SetTimelineNodeNameCommand, SetTimelineNodeNotesCommand, - SetTimelineNodeRangeCommand, + ReorderTimelineSiblingCommand, SetTimelineNodeArcsCommand, SetTimelineNodeLockCommand, + SetTimelineNodeNameCommand, SetTimelineNodeNotesCommand, SetTimelineNodeRangeCommand, }; use eidetic_server::command_service; use eidetic_server::projection_service; @@ -37,6 +37,17 @@ pub async fn command_timeline_create_child_from_parent( .map_err(CommandError::from) } +#[tauri::command] +pub async fn command_timeline_node_arcs( + app: tauri::AppHandle, + command: CommandEnvelope, +) -> Result { + let state = app.state::().inner().clone(); + command_service::set_timeline_node_arcs(&state, command) + .await + .map_err(CommandError::from) +} + #[tauri::command] pub async fn command_timeline_node_name( app: tauri::AppHandle, @@ -174,3 +185,14 @@ mod tests { assert_eq!(super::clamp_timeline_playhead(240_000, 120_000), 120_000); } } + +#[tauri::command] +pub async fn command_timeline_sibling_reorder( + app: tauri::AppHandle, + command: CommandEnvelope, +) -> Result { + let state = app.state::().inner().clone(); + command_service::reorder_timeline_sibling(&state, command) + .await + .map_err(CommandError::from) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8f3d98b6..efe57127 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -155,6 +155,8 @@ 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_arcs, + commands::timeline::command_timeline_sibling_reorder, commands::timeline::command_timeline_node_name, commands::timeline::command_timeline_node_range, commands::timeline::command_timeline_node_lock, diff --git a/src-tauri/src/timeline_renderer_window_thread.rs b/src-tauri/src/timeline_renderer_window_thread.rs index 6611d494..906849b9 100644 --- a/src-tauri/src/timeline_renderer_window_thread.rs +++ b/src-tauri/src/timeline_renderer_window_thread.rs @@ -217,7 +217,9 @@ mod tests { ) .unwrap(); - let status = handle.stop(Duration::from_millis(200)); + // Observe the terminal panic result; panic hooks and OS scheduling are + // not required to finish within the short graceful-close test budget. + let status = handle.stop(Duration::from_secs(5)); assert!(!status.running); assert_eq!( diff --git a/ui/package-lock.json b/ui/package-lock.json index de5b74ee..80f4d54b 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -10,12 +10,12 @@ "devDependencies": { "@eslint/js": "^9.38.0", "@sveltejs/adapter-static": "^3.0.8", - "@sveltejs/kit": "^2.16.0", + "@sveltejs/kit": "^2.70.3", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@tailwindcss/vite": "^4.0.0", "@types/node": "^25.2.3", "@typescript-eslint/types": "^8.56.1", - "@vitest/coverage-v8": "^3.2.4", + "@vitest/coverage-v8": "^4.1.11", "eslint": "^9.38.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.12.4", @@ -28,28 +28,14 @@ "tailwindcss": "^4.0.0", "typescript": "^5.7.0", "typescript-eslint": "^8.46.2", - "vite": "^6.1.0", - "vitest": "^3.2.4" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" + "vite": "^6.4.4", + "vitest": "^4.1.11" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -57,9 +43,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -67,13 +53,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -83,14 +69,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -680,29 +666,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -731,34 +731,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -804,17 +776,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1197,9 +1158,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.61.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.61.1.tgz", - "integrity": "sha512-Ny8s1SR1TyQS2hD2Rvw0XKzU2Nw1eUF52dTb6T2bdcgz7wSC+Nyb5IwjWYlR4b2dvbbR5NJDiQwHg3rnNseghg==", + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", "dev": true, "license": "MIT", "peer": true, @@ -1489,6 +1450,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", @@ -1789,16 +1810,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -1873,32 +1894,30 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1907,39 +1926,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1951,42 +1971,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -1994,28 +2014,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2062,19 +2079,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2119,9 +2123,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", - "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", "dev": true, "license": "MIT", "dependencies": { @@ -2147,9 +2151,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -2157,16 +2161,6 @@ "concat-map": "0.0.1" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2178,18 +2172,11 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -2211,16 +2198,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2272,6 +2249,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -2326,16 +2310,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2362,23 +2336,9 @@ } }, "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", "dev": true, "license": "MIT" }, @@ -2396,9 +2356,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -2696,9 +2656,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2794,23 +2754,6 @@ "dev": true, "license": "ISC" }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2825,28 +2768,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2860,32 +2781,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/globals": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", @@ -2969,16 +2864,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3033,21 +2918,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -3062,22 +2932,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -3095,10 +2949,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -3619,20 +3483,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3643,15 +3493,15 @@ } }, "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" } }, "node_modules/make-dir": { @@ -3683,16 +3533,6 @@ "node": "*" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -3718,9 +3558,9 @@ "dev": true }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -3743,6 +3583,20 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3793,13 +3647,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3833,23 +3680,6 @@ "node": ">=8" } }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3857,16 +3687,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3888,9 +3708,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", "dev": true, "funding": [ { @@ -3909,9 +3729,9 @@ "license": "MIT", "peer": true, "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -4012,9 +3832,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4202,19 +4022,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -4230,10 +4037,11 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -4246,116 +4054,12 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4369,26 +4073,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4504,60 +4188,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4566,11 +4196,14 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.15", @@ -4588,30 +4221,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -4715,9 +4328,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", "dev": true, "license": "MIT", "peer": true, @@ -4790,29 +4403,6 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitefu": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", @@ -4828,66 +4418,80 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -4898,6 +4502,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -4944,101 +4551,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/ui/package.json b/ui/package.json index ae6cea1a..4cb91b0d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -19,12 +19,12 @@ "devDependencies": { "@eslint/js": "^9.38.0", "@sveltejs/adapter-static": "^3.0.8", - "@sveltejs/kit": "^2.16.0", + "@sveltejs/kit": "^2.70.3", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@tailwindcss/vite": "^4.0.0", "@types/node": "^25.2.3", "@typescript-eslint/types": "^8.56.1", - "@vitest/coverage-v8": "^3.2.4", + "@vitest/coverage-v8": "^4.1.11", "eslint": "^9.38.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.12.4", @@ -37,7 +37,7 @@ "tailwindcss": "^4.0.0", "typescript": "^5.7.0", "typescript-eslint": "^8.46.2", - "vite": "^6.1.0", - "vitest": "^3.2.4" + "vite": "^6.4.4", + "vitest": "^4.1.11" } } diff --git a/ui/src/lib/README.md b/ui/src/lib/README.md index 12c80ff6..dfb07734 100644 --- a/ui/src/lib/README.md +++ b/ui/src/lib/README.md @@ -192,3 +192,11 @@ boundary, preserving unrelated author drafts and backend source/version custody. 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. + +## Guarded arc assignment and sibling reorder + +`timelineCommandApi.ts` sends `SetTimelineNodeArcsCommand` and +`ReorderTimelineSiblingCommand` through the existing desktop command envelope. +Owned expected reads, original command IDs and replay receipts remain backend +contracts; frontend drafts cannot manufacture a replacement current read after +an uncertain write. `timelineCommandTypes.ts` mirrors those contracts. diff --git a/ui/src/lib/commandApi.ts b/ui/src/lib/commandApi.ts index a503c844..2a1323b4 100644 --- a/ui/src/lib/commandApi.ts +++ b/ui/src/lib/commandApi.ts @@ -75,6 +75,8 @@ export { setTimelinePlayhead, setTimelineNodeLock, setTimelineNodeNotes, + reorderTimelineSibling, + setTimelineNodeArcs, setTimelineNodeName, setTimelineNodeRange, splitTimelineNode, diff --git a/ui/src/lib/components/editor/BeatEditor.svelte b/ui/src/lib/components/editor/BeatEditor.svelte index d53174c8..fc199a47 100644 --- a/ui/src/lib/components/editor/BeatEditor.svelte +++ b/ui/src/lib/components/editor/BeatEditor.svelte @@ -28,6 +28,8 @@ import BeatEditorHeader from './BeatEditorHeader.svelte'; import TimelinePlacementEditor from './TimelinePlacementEditor.svelte'; import TimelineTitleEditor from './TimelineTitleEditor.svelte'; + import TimelineSiblingReorderEditor from './TimelineSiblingReorderEditor.svelte'; + import TimelineArcAssignmentEditor from './TimelineArcAssignmentEditor.svelte'; import BeatNotesPanel from './BeatNotesPanel.svelte'; import BeatPlanningActions from './BeatPlanningActions.svelte'; import ChildPlanReview from './ChildPlanReview.svelte'; @@ -333,6 +335,8 @@ {#if selectedProjectionNode} + + {/if} {#if childCreateError}

{childCreateError}

{/if} diff --git a/ui/src/lib/components/editor/README.md b/ui/src/lib/components/editor/README.md index 0df5140a..280b0d83 100644 --- a/ui/src/lib/components/editor/README.md +++ b/ui/src/lib/components/editor/README.md @@ -28,6 +28,8 @@ This directory contains the main beat and script viewing workflow, including AI | `scriptBlockCreationDraft.svelte.test.ts` | Refused/ambiguous save, exact text and captured-context frontend fixtures. | | `scriptBlockCreationRetry.svelte.test.ts` | Lost acknowledgement, exact retry, guarded discard/retarget and definite native placement refusal through actual frontend command/store functions with an invoke fixture. | | `ScriptBlockComposer.retry.ssr.test.ts` | Uncertain-save read-only controls, exact retry action and definite-refusal recovery rendering fixtures. | +| `ScriptMissingDraft.svelte`, `scriptMissingDraft.ts` | Visible missing-block drafts, exact original retry and explicit copy into the existing selected-clip composer. | +| `scriptMissingDraft.svelte.test.ts` | Removal rendering, independent drafts, read failure/return, guarded copy, cancel and immutable retry. | | `ScriptBlockEditor.svelte` | Session-owned per-block edit/save/cancel surface with exact uncertain retry and explicit reload. | | `scriptBlockEditDraft.svelte.ts` | Captured draft, immutable pending edit, read comparison and explicit continuation from that version. | | `scriptBlockEditComparison.svelte.test.ts` | Exact draft/current-text comparison, explicit version continuation, stale/ABA/read failures, navigation, immutable retry and retired-session fixtures. | @@ -263,3 +265,14 @@ owner preserves drafts through navigation and retries immutable uncertain writes `ScriptTimelineTitleEvidence` shows original/current changed consumed names or owned removal. Unchanged supplied titles remain bound to the proposal. Existing Preview update and Accept update remain separate replacement actions. + +Missing saved blocks do not hide their session-owned manual drafts or uncertain +original saves. ScriptMissingDraft labels absence from the current view without +asserting deletion after failed reads. It preserves original block/revision +identity and offers existing read/retry guards. Explicit copy captures exact text +and original kind into the idle existing composer for the visibly selected clip; +it neither restores the old block nor discards the source draft. A busy/uncertain +source or active composer refuses copy. Cancel of the copy leaves the source +intact. Only ordinary Save creates new canonical UserEdited material, using the +existing downstream review and explicit acceptance workflow. Same-ID return +reattaches the same edit owner and original revision. diff --git a/ui/src/lib/components/editor/ScriptArcMembershipEvidence.ssr.test.ts b/ui/src/lib/components/editor/ScriptArcMembershipEvidence.ssr.test.ts new file mode 100644 index 00000000..99accc41 --- /dev/null +++ b/ui/src/lib/components/editor/ScriptArcMembershipEvidence.ssr.test.ts @@ -0,0 +1,32 @@ +import { expect, it } from 'vitest'; +import { render } from 'svelte/server'; +import ScriptArcMembershipEvidence from './ScriptArcMembershipEvidence.svelte'; +it('shows exact original/current identities, names, known empty and explicit acceptance boundary', () => { + const html = render(ScriptArcMembershipEvidence, { + props: { + previous: { node_id: 'clip', arc_ids: ['arc.A'], revision_event_id: 'old' }, + current: { node_id: 'clip', arc_ids: [], revision_event_id: 'clear' }, + previousFields: [ + { arc_id: 'arc.A', field: 'name', value: 'Witness — 雨.', revision_event_id: 'name.old' }, + ], + }, + }).body; + for (const text of [ + 'Originally consumed arc assignment', + 'Current preview arc assignment', + 'Witness — 雨.', + 'arc.A', + '(no arcs)', + 'Revision old', + 'Revision clear', + 'Accept update replaces only the selected block', + ]) + expect(html.replace(/\s+/g, ' ')).toContain(text); +}); +it('legacy absence remains unknown while clock-free known empty is labelled baseline', () => { + const html = render(ScriptArcMembershipEvidence, { + props: { current: { node_id: 'clip', arc_ids: [], revision_event_id: null } }, + }).body; + expect(html).toContain('Original arc assignment consumption is unknown'); + expect(html).toContain('known baseline; no assignment event'); +}); diff --git a/ui/src/lib/components/editor/ScriptArcMembershipEvidence.svelte b/ui/src/lib/components/editor/ScriptArcMembershipEvidence.svelte new file mode 100644 index 00000000..5bb9a362 --- /dev/null +++ b/ui/src/lib/components/editor/ScriptArcMembershipEvidence.svelte @@ -0,0 +1,51 @@ + + +{#if previous || current} +
+ Story arc assignment used for this update + {#if !previous}

Original arc assignment consumption is unknown.

{/if} + {#each [{ label: 'Originally consumed arc assignment', input: previous, fields: previousFields }, { label: 'Current preview arc assignment', input: current, fields: currentFields }] as group (group.label)} + {#if group.input} + {group.label} +
    + {#each group.input.arc_ids as id (id)}
  • + {group.fields?.find((field) => field.arc_id === id && field.field === 'name') + ?.value ?? 'Arc'} · {id} +
  • {/each} +
+ {#if group.input.arc_ids.length === 0}

(no arcs)

{/if} + Clip {group.input.node_id} · Revision {group.input.revision_event_id ?? + 'known baseline; no assignment event'} + {/if} + {/each} +

+ Saving arc assignment and previewing preserve saved screenplay. Accept update replaces only + the selected block. +

+
+{/if} + + diff --git a/ui/src/lib/components/editor/ScriptImpactReview.svelte b/ui/src/lib/components/editor/ScriptImpactReview.svelte index 9a0509a9..efdc485a 100644 --- a/ui/src/lib/components/editor/ScriptImpactReview.svelte +++ b/ui/src/lib/components/editor/ScriptImpactReview.svelte @@ -1,4 +1,5 @@ + +
+ Your draft is retained +

+ This saved block is unavailable in the current screenplay view. Check saved screenplay before + deciding what to do. +

+
+ Original source +

Block: {editor.blockId}

+

Saved revision: {draft.baseRevision}

+
+ + + {#if draft.error}

{draft.error} Your draft is still here.

{/if} + {#if draft.uncertain}

+ The original save may have completed. Retry that exact save before copying or discarding. +

{/if} +
+ {#if draft.uncertain}{/if} + + + +
+ {#if source}

+ Copy starts a new block for {source.name}. Review it in Write screenplay and + explicitly Save. This original draft stays here until discarded. +

{:else}

Select a timeline clip to copy this text into a new screenplay draft.

{/if} +
+ + diff --git a/ui/src/lib/components/editor/ScriptPanel.svelte b/ui/src/lib/components/editor/ScriptPanel.svelte index 9de5d403..8e38962b 100644 --- a/ui/src/lib/components/editor/ScriptPanel.svelte +++ b/ui/src/lib/components/editor/ScriptPanel.svelte @@ -1,7 +1,11 @@ + +
+ Story arcs + {#each arcs as arc (arc.id)} + + {/each} + {#each unavailable as id (id)} + + {/each} + {#if arcs.length === 0 && unavailable.length === 0}

+ Create a story arc in the Arcs panel to assign it here. +

{/if} +
+
+ + +
+{#if dirty}

+ Unsaved arc assignment. Kept while you switch clips or panels in this project session. +

{/if} +{#if draft.state.saved}

+ Arc assignment saved. Affected generated screenplay can be reviewed before replacement. +

{/if} +{#if changed}

+ Saved arc assignment changed since this draft was read. Your draft is kept. +

+
+ Current saved arc assignment +

+ {draft.state.current?.arc_ids + .map((id) => arcs.find((arc) => arc.id === id)?.name ?? id) + .join(', ') || '(no arcs)'} +

+
{/if} +{#if draft.state.uncertain}

+ Save completion is uncertain. Retry the original arc save before discarding this draft. +

{/if} +{#if draft.state.error}

{draft.state.error}

{/if} + + diff --git a/ui/src/lib/components/editor/TimelineSiblingReorderEditor.svelte b/ui/src/lib/components/editor/TimelineSiblingReorderEditor.svelte new file mode 100644 index 00000000..5d7b0939 --- /dev/null +++ b/ui/src/lib/components/editor/TimelineSiblingReorderEditor.svelte @@ -0,0 +1,117 @@ + + +
+ Sibling order + + + {#if !earlier && !later}

No adjacent sibling clips on this track.

{/if} + {#if draft.state.neighborId} +

Unsaved reorder draft. Kept while you switch clips or panels in this project session.

+ {#if preview}

+ Swap with {draft.state.base?.siblings.find((n) => n.node_id === draft.state.neighborId) + ?.name}. Durations and the gap stay the same; children move with their clip. +

+ {#each preview.siblings.filter((n) => n.node_id === node.node_id || n.node_id === draft.state.neighborId) as clip (clip.node_id)}

+ {clip.name}: {clip.start_ms}–{clip.end_ms} ms +

{/each} + {:else}

+ These siblings overlap or their placement and order disagree. Read and adjust their + placement before reordering. +

{/if} + {/if} +
+
+ + +
+{#if changed}

+ Saved siblings changed since this draft was read. Your reorder draft is kept. +

{/if} +{#if draft.state.saved}

+ Sibling reorder saved. Affected generated screenplay can be reviewed before replacement. +

{/if} +{#if draft.state.uncertain}

+ Reorder completion is uncertain. Retry the original pair before discarding this draft. +

{/if} +{#if draft.state.error}

{draft.state.error}

{/if} + + diff --git a/ui/src/lib/components/editor/scriptBlockCreationDraft.svelte.ts b/ui/src/lib/components/editor/scriptBlockCreationDraft.svelte.ts index 307c0021..dba2955a 100644 --- a/ui/src/lib/components/editor/scriptBlockCreationDraft.svelte.ts +++ b/ui/src/lib/components/editor/scriptBlockCreationDraft.svelte.ts @@ -21,7 +21,10 @@ export function createScriptBlockCreationDraft(options: { newCommandId?: () => string; }): { readonly state: CreationDraft; - begin: (node: SelectedNodeEditorNode) => void; + begin: ( + node: SelectedNodeEditorNode, + initial?: { text: string; kind: ScriptBlockKind }, + ) => boolean; cancel: () => void; save: () => Promise; useCurrentPlacement: (node: SelectedNodeEditorNode) => Promise; @@ -39,8 +42,11 @@ export function createScriptBlockCreationDraft(options: { placementRefused: false, }); let submitted: { payload: CreateScriptBlockCommand; commandId: string } | null = null; - function begin(node: SelectedNodeEditorNode): void { - if (state.saving || submitted) return; + function begin( + node: SelectedNodeEditorNode, + initial?: { text: string; kind: ScriptBlockKind }, + ): boolean { + if (state.writing || state.saving || submitted) return false; state.target = { node_id: node.node_id, name: node.name, @@ -49,12 +55,13 @@ export function createScriptBlockCreationDraft(options: { }; state.documentId = options.documentId(); state.commandId = (options.newCommandId ?? createCommandId)(); - state.text = ''; - state.kind = 'action'; + state.text = initial?.text ?? ''; + state.kind = initial?.kind ?? 'action'; state.error = null; state.uncertain = false; state.placementRefused = false; state.writing = true; + return true; } function cancel(): void { if (state.saving || submitted) return; diff --git a/ui/src/lib/components/editor/scriptBlockEditDraft.svelte.ts b/ui/src/lib/components/editor/scriptBlockEditDraft.svelte.ts index 975bae91..a3826fd1 100644 --- a/ui/src/lib/components/editor/scriptBlockEditDraft.svelte.ts +++ b/ui/src/lib/components/editor/scriptBlockEditDraft.svelte.ts @@ -3,6 +3,7 @@ import type { EditScriptBlockCommand, RemoveScriptBlockCommand, ScriptBlockProjection, + ScriptBlockKind, } from '$lib/scriptTypes.js'; interface SavedComparison { @@ -35,6 +36,7 @@ export function createScriptBlockEditDraft(options: { error: null as string | null, uncertain: false, }); + let originalKind: ScriptBlockKind = 'action'; let compared: SavedComparison | null = null; let submitted: { payload: EditScriptBlockCommand; commandId: string } | null = null; @@ -103,6 +105,7 @@ export function createScriptBlockEditDraft(options: { block.block.id !== options.blockId ) return; + originalKind = block.block.block_kind; state.text = block.block.text; state.baseRevision = block.revision_event_id ?? null; state.error = null; @@ -205,6 +208,11 @@ export function createScriptBlockEditDraft(options: { state.comparison = null; } return { + documentId: options.documentId, + blockId: options.blockId, + get originalKind() { + return originalKind; + }, state, begin, cancel, diff --git a/ui/src/lib/components/editor/scriptImpactNotice.ts b/ui/src/lib/components/editor/scriptImpactNotice.ts index 46201502..9f486ee9 100644 --- a/ui/src/lib/components/editor/scriptImpactNotice.ts +++ b/ui/src/lib/components/editor/scriptImpactNotice.ts @@ -1,6 +1,8 @@ import type { ScriptImpactCause } from '$lib/scriptTypes.js'; export function scriptImpactCauseLabel(cause: ScriptImpactCause): string { + if (cause.input.kind === 'timeline_node' && cause.dependency_id.endsWith('.arc_membership')) + return 'Story arc assignment changed.'; if ( cause.input.kind === 'timeline_node' && cause.dependency_id.endsWith(`.timeline_title.${cause.input.node_id}`) diff --git a/ui/src/lib/components/editor/scriptMissingDraft.svelte.test.ts b/ui/src/lib/components/editor/scriptMissingDraft.svelte.test.ts new file mode 100644 index 00000000..7cd8ad95 --- /dev/null +++ b/ui/src/lib/components/editor/scriptMissingDraft.svelte.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { render } from 'svelte/server'; +import ScriptPanel from './ScriptPanel.svelte'; +import { + getSessionScriptBlockEditDraft, + resetSessionScriptBlockEditDrafts, +} from '$lib/stores/scriptBlockEditSession.svelte.js'; +import { resetSessionScriptBlockCreationDraft } from '$lib/stores/scriptBlockCreationSession.svelte.js'; +import { + clearScriptDocumentProjection, + refreshScriptDocumentProjection, + MAIN_SCRIPT_DOCUMENT_ID, +} from '$lib/stores/scriptDocumentProjection.svelte.js'; +import { getSessionScriptBlockCreationDraft } from '$lib/stores/scriptBlockCreationSession.svelte.js'; +import { copyMissingScriptDraft } from './scriptMissingDraft.js'; +import { createScriptBlockCreationDraft } from './scriptBlockCreationDraft.svelte.js'; +import type { SelectedNodeEditorNode } from '$lib/selectedNodeEditorTypes.js'; +import type { ScriptBlockProjection } from '$lib/scriptTypes.js'; +const key = { document_id: MAIN_SCRIPT_DOCUMENT_ID }; +const block: ScriptBlockProjection = { + block: { + id: 'missing.A', + segment_id: 'segment.A', + block_kind: 'dialogue', + text: 'Saved original', + sort_order: 0, + }, + revision_event_id: 'original.revision', + spans: [], + locks: [], +}; +const exact = ' Keep my dialogue — 雨.\n\n '; +const invoke = vi.fn(); +beforeEach(() => { + resetSessionScriptBlockEditDrafts(); + resetSessionScriptBlockCreationDraft(); + clearScriptDocumentProjection(key); + invoke.mockReset(); + vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); +}); +afterEach(() => vi.unstubAllGlobals()); +it('keeps an exact authored draft reachable after its canonical block disappears', async () => { + const editor = getSessionScriptBlockEditDraft(key.document_id, block.block.id); + editor.begin(block); + editor.state.text = exact; + invoke.mockResolvedValueOnce({ + version: 2, + payload: { document: { id: key.document_id, title: 'Story', sort_order: 0 }, segments: [] }, + }); + await refreshScriptDocumentProjection(key); + const body = render(ScriptPanel).body; + expect(body).toContain('Keep my dialogue — 雨.'); + expect(editor.state.text).toBe(exact); + expect(editor.state.baseRevision).toBe('original.revision'); + expect(body).toContain('Discard retained draft'); + expect(invoke).toHaveBeenCalledTimes(1); +}); + +const source: SelectedNodeEditorNode = { + node_id: 'scene.selected', + name: 'Explicit selected scene', + level: 'Scene', + sort_order: 0, + start_ms: 1000, + end_ms: 2000, + notes: '', + locked: false, + content_status: 'Empty', +}; +function beginMissing() { + const editor = getSessionScriptBlockEditDraft(key.document_id, block.block.id); + editor.begin(block); + editor.state.text = exact; + return editor; +} +it('copies exact text and original kind into the existing composer only explicitly, retaining original across copy cancellation', () => { + const editor = beginMissing(), + composer = getSessionScriptBlockCreationDraft(); + expect(copyMissingScriptDraft(editor, composer, null)).toBe(false); + expect(copyMissingScriptDraft(editor, composer, source)).toBe(true); + expect(composer.state.text).toBe(exact); + expect(composer.state.kind).toBe('dialogue'); + expect(composer.state.target?.node_id).toBe(source.node_id); + composer.state.text = 'Independently edited copy'; + expect(copyMissingScriptDraft(editor, composer, source)).toBe(false); + expect(composer.state.text).toBe('Independently edited copy'); + composer.cancel(); + expect(editor.state.text).toBe(exact); + expect(editor.state.baseRevision).toBe('original.revision'); + expect(copyMissingScriptDraft(editor, composer, source)).toBe(true); + expect(composer.state.text).toBe(exact); + expect(invoke).not.toHaveBeenCalled(); +}); +it('blocks repeated copies and cancellation during pending/uncertain creation, retries the immutable copy once, and never retires the original', async () => { + const editor = beginMissing(); + let reject!: (e: Error) => void; + const save = vi + .fn() + .mockImplementationOnce( + () => + new Promise((_ok, fail) => { + reject = fail; + }), + ) + .mockResolvedValueOnce({}); + const composer = createScriptBlockCreationDraft({ + documentId: () => key.document_id, + save, + newCommandId: () => 'exact.copy.request', + }); + copyMissingScriptDraft(editor, composer, source); + const pending = composer.save(); + const first = structuredClone(save.mock.calls[0]); + expect(copyMissingScriptDraft(editor, composer, source)).toBe(false); + composer.cancel(); + await composer.save(); + expect(save).toHaveBeenCalledTimes(1); + reject(new Error('Labelled lost acknowledgment')); + await pending; + composer.cancel(); + expect(composer.state.writing).toBe(true); + expect(copyMissingScriptDraft(editor, composer, source)).toBe(false); + composer.state.text = 'Caller mutation cannot replace submission'; + await composer.save(); + expect(save.mock.calls[1]).toEqual(first); + expect(first?.[0]).toMatchObject({ + text: exact, + block_kind: 'dialogue', + source_node_id: source.node_id, + }); + expect(editor.state.text).toBe(exact); + expect(editor.state.editing).toBe(true); + expect(composer.state.writing).toBe(false); + editor.cancel(); + expect(editor.state.editing).toBe(false); +}); +it('keeps an unavailable original save retry reachable and refuses copy/discard until its exact acknowledgment', async () => { + const editor = beginMissing(); + invoke.mockRejectedValueOnce(new Error('Labelled lost original acknowledgment')); + await editor.save(); + const first = structuredClone(invoke.mock.calls[0]); + invoke.mockResolvedValueOnce({ + version: 2, + payload: { document: { id: key.document_id, title: 'Story', sort_order: 0 }, segments: [] }, + }); + await refreshScriptDocumentProjection(key); + const body = render(ScriptPanel).body; + expect(body).toContain('Retry original save'); + expect(body).toContain('Keep my dialogue — 雨.'); + const composer = getSessionScriptBlockCreationDraft(); + expect(copyMissingScriptDraft(editor, composer, source)).toBe(false); + editor.cancel(); + expect(editor.state.text).toBe(exact); + invoke.mockResolvedValueOnce({ + outcome: 'already_recorded', + projection: { + version: 3, + payload: { document: { id: key.document_id, title: 'Story', sort_order: 0 }, segments: [] }, + }, + }); + await editor.save(); + expect(invoke.mock.calls[2]).toEqual(first); + expect(editor.state.editing).toBe(false); +}); +it('preserves independent drafts, fresh panel consumers and read failures, then reattaches the same owner when its block returns', async () => { + const editor = beginMissing(); + const other = getSessionScriptBlockEditDraft(key.document_id, 'missing.B'); + other.begin({ ...block, block: { ...block.block, id: 'missing.B' } }); + other.state.text = 'Independent B'; + invoke.mockResolvedValueOnce({ + version: 2, + payload: { document: { id: key.document_id, title: 'Story', sort_order: 0 }, segments: [] }, + }); + await refreshScriptDocumentProjection(key); + for (let i = 0; i < 2; i++) expect(render(ScriptPanel).body).toContain('Keep my dialogue — 雨.'); + invoke.mockRejectedValueOnce(new Error('Read unavailable')); + await editor.compare(); + expect(editor.state.text).toBe(exact); + expect(editor.state.baseRevision).toBe('original.revision'); + expect(editor.state.comparison).toBeNull(); + expect(other.state.text).toBe('Independent B'); + invoke.mockResolvedValueOnce({ + version: 3, + payload: { + document: { id: key.document_id, title: 'Story', sort_order: 0 }, + segments: [ + { + segment: { + id: 'segment.A', + document_id: key.document_id, + source_node_id: source.node_id, + start_ms: 1000, + end_ms: 2000, + status: 'current', + sort_order: 0, + }, + blocks: [{ ...block, revision_event_id: 'new.revision' }], + }, + ], + }, + }); + await refreshScriptDocumentProjection(key); + const body = render(ScriptPanel).body; + expect(body).toContain('Keep my dialogue — 雨.'); + expect(body).toContain('Edit screenplay text'); + expect(getSessionScriptBlockEditDraft(key.document_id, block.block.id)).toBe(editor); + expect(editor.state.baseRevision).toBe('original.revision'); + editor.cancel(); + expect(other.state.text).toBe('Independent B'); + resetSessionScriptBlockEditDrafts(); + expect(render(ScriptPanel).body).not.toContain('Independent B'); +}); diff --git a/ui/src/lib/components/editor/scriptMissingDraft.ts b/ui/src/lib/components/editor/scriptMissingDraft.ts new file mode 100644 index 00000000..060ffa2b --- /dev/null +++ b/ui/src/lib/components/editor/scriptMissingDraft.ts @@ -0,0 +1,26 @@ +import type { createScriptBlockEditDraft } from './scriptBlockEditDraft.svelte.js'; +import type { createScriptBlockCreationDraft } from './scriptBlockCreationDraft.svelte.js'; +import type { SelectedNodeEditorNode } from '$lib/selectedNodeEditorTypes.js'; + +// Copying starts an ordinary new draft, never restores canon or retires the source. +export function copyMissingScriptDraft( + editor: ReturnType, + composer: ReturnType, + source: SelectedNodeEditorNode | null, +): boolean { + const draft = editor.state; + if ( + !source || + !draft.editing || + !draft.baseRevision || + draft.saving || + draft.comparing || + draft.uncertain || + draft.removal.active || + composer.state.writing || + composer.state.saving || + composer.state.uncertain + ) + return false; + return composer.begin(source, { text: draft.text, kind: editor.originalKind }); +} diff --git a/ui/src/lib/components/editor/timelineArcsDraft.svelte.test.ts b/ui/src/lib/components/editor/timelineArcsDraft.svelte.test.ts new file mode 100644 index 00000000..299b11a7 --- /dev/null +++ b/ui/src/lib/components/editor/timelineArcsDraft.svelte.test.ts @@ -0,0 +1,140 @@ +import { expect, it, vi } from 'vitest'; +import { createTimelineArcsDraft } from './timelineArcsDraft.svelte.js'; +import type { + SetTimelineNodeArcsCommand, + TimelineArcMembershipInput, +} from '$lib/timelineCommandTypes.js'; +const initial: TimelineArcMembershipInput = { + node_id: 'clip', + arc_ids: [], + revision_event_id: null, +}; +function fixture() { + const apply = vi.fn().mockImplementation(async (payload: SetTimelineNodeArcsCommand) => ({ + ...initial, + arc_ids: [...payload.arc_ids], + revision_event_id: 'ack', + })); + const read = vi + .fn() + .mockResolvedValue({ ...initial, arc_ids: ['current'], revision_event_id: 'new' }); + const commandId = vi.fn().mockReturnValueOnce('save-1').mockReturnValueOnce('save-2'); + const draft = createTimelineArcsDraft({ nodeId: 'clip', apply, read, commandId }); + draft.observe(initial); + return { draft, apply, read, commandId }; +} +it('assigns and clears only on explicit saves; reordered sets produce no write', async () => { + const { draft, apply } = fixture(); + await draft.apply(); + expect(apply).not.toHaveBeenCalled(); + draft.state.arcIds = ['b', 'a']; + draft.observe(initial); + expect(apply).not.toHaveBeenCalled(); + await draft.apply(); + expect(apply).toHaveBeenCalledWith( + { node_id: 'clip', arc_ids: ['a', 'b'], expected: initial }, + 'save-1', + ); + draft.state.arcIds = ['a', 'b']; + await draft.apply(); + expect(apply).toHaveBeenCalledTimes(1); + draft.state.arcIds = []; + await draft.apply(); + expect(apply).toHaveBeenCalledTimes(2); + expect(apply.mock.calls[1]![0].expected.arc_ids).toEqual(['a', 'b']); +}); +it('retains dirty intent for changed saved membership and same-set ABA', async () => { + for (const current of [ + { ...initial, arc_ids: ['other'], revision_event_id: 'other' }, + { ...initial, revision_event_id: 'restored' }, + ]) { + const { draft, apply } = fixture(); + draft.state.arcIds = ['mine']; + draft.observe(current); + await draft.apply(); + expect(apply).not.toHaveBeenCalled(); + expect(draft.state.arcIds).toEqual(['mine']); + expect(draft.state.base).toEqual(initial); + } +}); +it('discard requires successful explicit same-clip read', async () => { + const { draft, read, apply } = fixture(); + draft.state.arcIds = ['mine']; + read.mockRejectedValueOnce(new Error('Unavailable')); + await draft.reload(); + expect(draft.state.arcIds).toEqual(['mine']); + read.mockResolvedValueOnce({ ...initial, node_id: 'other' }); + await draft.reload(); + expect(draft.state.arcIds).toEqual(['mine']); + await draft.reload(); + expect(draft.state.arcIds).toEqual(['current']); + expect(apply).not.toHaveBeenCalled(); +}); +it('retains immutable arrays and original command across uncertain transport and state mutation', async () => { + const { draft, apply, read, commandId } = fixture(); + draft.state.arcIds = ['mine']; + draft.state.base!.arc_ids.push('forged'); + apply.mockImplementationOnce(async (p: SetTimelineNodeArcsCommand) => { + p.arc_ids.push('mutated'); + p.expected.arc_ids.push('mutated'); + throw new Error('Lost ack'); + }); + await draft.apply(); + expect(draft.state.uncertain).toBe(true); + draft.state.arcIds = ['later']; + await draft.reload(); + expect(read).not.toHaveBeenCalled(); + await draft.apply(); + expect(apply.mock.calls[1]).toEqual([ + { node_id: 'clip', arc_ids: ['mine'], expected: initial }, + 'save-1', + ]); + expect(commandId).toHaveBeenCalledTimes(1); + expect(draft.state.arcIds).toEqual(['later']); + expect(draft.state.base?.arc_ids).toEqual(['mine']); +}); +it('acknowledges original receipt without adopting newer canonical membership', async () => { + const { draft, apply } = fixture(); + let resolve!: (r: TimelineArcMembershipInput) => void; + apply.mockReturnValueOnce(new Promise((done) => (resolve = done))); + draft.state.arcIds = ['submitted']; + const pending = draft.apply(); + draft.observe({ ...initial, arc_ids: ['concurrent'], revision_event_id: 'newer' }); + draft.state.arcIds = ['later']; + resolve({ ...initial, arc_ids: ['submitted'], revision_event_id: 'ack' }); + await pending; + await draft.apply(); + expect(apply).toHaveBeenCalledTimes(1); + expect(draft.state.arcIds).toEqual(['later']); + expect(draft.state.current?.arc_ids).toEqual(['concurrent']); +}); +it('mismatched receipts remain uncertain and exact retries retain identity', async () => { + for (const receipt of [ + null, + { ...initial, node_id: 'other', arc_ids: ['mine'], revision_event_id: 'bad' }, + { ...initial, arc_ids: ['wrong'], revision_event_id: 'bad' }, + { ...initial, arc_ids: ['mine'] }, + ]) { + const { draft, apply } = fixture(); + draft.state.arcIds = ['mine']; + apply.mockResolvedValueOnce(receipt); + await draft.apply(); + expect(draft.state.uncertain).toBe(true); + await draft.apply(); + expect(apply.mock.calls[1]).toEqual(apply.mock.calls[0]); + } +}); +it('only typed precommit refusals release retry custody', async () => { + for (const proven of [true, false]) { + const { draft, apply, read } = fixture(); + draft.state.arcIds = ['mine']; + const message = 'Arc assignment refused: stale'; + apply.mockRejectedValueOnce( + new Error(message, proven ? { cause: { kind: 'conflict', message } } : undefined), + ); + await draft.apply(); + expect(draft.state.uncertain).toBe(!proven); + await draft.reload(); + expect(read).toHaveBeenCalledTimes(proven ? 1 : 0); + } +}); diff --git a/ui/src/lib/components/editor/timelineArcsDraft.svelte.ts b/ui/src/lib/components/editor/timelineArcsDraft.svelte.ts new file mode 100644 index 00000000..cc58fbb4 --- /dev/null +++ b/ui/src/lib/components/editor/timelineArcsDraft.svelte.ts @@ -0,0 +1,135 @@ +import type { + TimelineArcMembershipInput, + SetTimelineNodeArcsCommand, +} from '$lib/timelineCommandTypes.js'; + +const copy = (input: TimelineArcMembershipInput): TimelineArcMembershipInput => ({ + ...input, + arc_ids: [...input.arc_ids], +}); +export const sameArcIds = (a: string[], b: string[]) => + a.length === b.length && [...a].sort().every((id, i) => id === [...b].sort()[i]); +const same = (a: TimelineArcMembershipInput | null, b: TimelineArcMembershipInput | null) => + !!a && + !!b && + a.node_id === b.node_id && + sameArcIds(a.arc_ids, b.arc_ids) && + a.revision_event_id === b.revision_event_id; + +/** Per-clip explicit intent; transport receives copies of privately retained retry custody. */ +export function createTimelineArcsDraft(options: { + nodeId: string; + read: () => Promise; + apply: ( + payload: SetTimelineNodeArcsCommand, + id: string, + ) => Promise; + commandId: () => string; +}) { + const state = $state({ + base: null as TimelineArcMembershipInput | null, + current: null as TimelineArcMembershipInput | null, + arcIds: [] as string[], + busy: false, + uncertain: false, + saved: false, + error: null as string | null, + }); + let base: TimelineArcMembershipInput | null = null; + let submission: { payload: SetTimelineNodeArcsCommand; id: string } | null = null; + function useRead(read: TimelineArcMembershipInput) { + base = copy(read); + state.base = copy(read); + state.current = copy(read); + state.arcIds = [...read.arc_ids]; + state.saved = false; + state.error = null; + } + function observe(read: TimelineArcMembershipInput | null | undefined) { + if (!read || read.node_id !== options.nodeId) return; + if (same(base, read)) state.current = copy(read); + else if (!submission && !state.busy && (!base || sameArcIds(state.arcIds, base.arc_ids))) + useRead(read); + else state.current = copy(read); + } + async function reload() { + if (state.busy || submission) return; + const ids = [...state.arcIds]; + state.busy = true; + state.error = null; + try { + const read = await options.read(); + if (!read || read.node_id !== options.nodeId) + throw new Error('Current arc assignment is unavailable; save the project and read again.'); + if (!sameArcIds(state.arcIds, ids)) + throw new Error('The draft changed during the read; your assignment was kept.'); + useRead(read); + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + } finally { + state.busy = false; + } + } + async function apply() { + if (state.busy || !base) return; + if (!submission) { + if (!same(base, state.current)) { + state.error = + 'Saved arc assignment changed. Your draft is kept; discard it and read current arcs before saving.'; + return; + } + if (sameArcIds(state.arcIds, base.arc_ids)) return; + submission = { + id: options.commandId(), + payload: { + node_id: options.nodeId, + arc_ids: [...state.arcIds].sort(), + expected: copy(base), + }, + }; + } + const accepted = submission; + state.busy = true; + state.error = null; + state.saved = false; + try { + const receipt = await options.apply( + { + ...accepted.payload, + arc_ids: [...accepted.payload.arc_ids], + expected: copy(accepted.payload.expected), + }, + accepted.id, + ); + if ( + !receipt || + receipt.node_id !== options.nodeId || + !sameArcIds(receipt.arc_ids, accepted.payload.arc_ids) || + !receipt.revision_event_id + ) + throw new Error( + 'The save receipt is unavailable. Retry this exact save to recover its result.', + ); + if (same(state.current, accepted.payload.expected)) state.current = copy(receipt); + base = copy(receipt); + state.base = copy(receipt); + submission = null; + state.uncertain = false; + state.saved = true; + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + const native = error instanceof Error ? error.cause : null; + const refused = + typeof native === 'object' && + native !== null && + 'kind' in native && + (native.kind === 'conflict' || native.kind === 'bad_request') && + state.error.startsWith('Arc assignment refused: '); + state.uncertain = !refused; + if (refused) submission = null; + } finally { + state.busy = false; + } + } + return { state, observe, reload, apply }; +} diff --git a/ui/src/lib/components/editor/timelineReorderDraft.svelte.test.ts b/ui/src/lib/components/editor/timelineReorderDraft.svelte.test.ts new file mode 100644 index 00000000..7d0df482 --- /dev/null +++ b/ui/src/lib/components/editor/timelineReorderDraft.svelte.test.ts @@ -0,0 +1,180 @@ +import { expect, it, vi } from 'vitest'; +import { createTimelineReorderDraft, reordered } from './timelineReorderDraft.svelte.js'; +import type { + ReorderTimelineSiblingCommand, + TimelineSiblingOrderRead, +} from '$lib/timelineCommandTypes.js'; +const initial: TimelineSiblingOrderRead = { + node_id: 'a', + parent_id: 'parent', + level: 'Scene', + membership_revision_event_id: null, + siblings: [ + { node_id: 'a', name: 'A', start_ms: 100, end_ms: 200, sort_order: 1, revision_event_id: null }, + { node_id: 'b', name: 'B', start_ms: 300, end_ms: 500, sort_order: 2, revision_event_id: null }, + ], +}; +function receipt(p: ReorderTimelineSiblingCommand) { + const r = reordered(p.expected, p.neighbor_id)!; + for (const n of r.siblings) n.revision_event_id = 'ack'; + return r; +} +function fixture() { + const apply = vi.fn().mockImplementation(async (p: ReorderTimelineSiblingCommand) => receipt(p)); + const read = vi.fn().mockResolvedValue(initial); + const commandId = vi.fn().mockReturnValue('swap-1'); + const draft = createTimelineReorderDraft({ nodeId: 'a', apply, read, commandId }); + draft.observe(initial); + return { draft, apply, read, commandId }; +} +it('choice is unwritten until explicit atomic save preserves duration/gap', async () => { + const { draft, apply } = fixture(); + draft.state.neighborId = 'b'; + expect(apply).not.toHaveBeenCalled(); + await draft.apply(); + expect(apply).toHaveBeenCalledWith( + { node_id: 'a', neighbor_id: 'b', expected: initial }, + 'swap-1', + ); + expect(draft.state.base?.siblings.map((n) => [n.node_id, n.start_ms, n.end_ms])).toEqual([ + ['b', 100, 300], + ['a', 400, 500], + ]); + await draft.apply(); + expect(apply).toHaveBeenCalledTimes(1); +}); +it('membership and owned-clock ABA keep draft and block stale write', async () => { + for (const current of [ + { ...initial, membership_revision_event_id: 'aba' }, + { + ...initial, + siblings: initial.siblings.map((n) => ({ ...n, revision_event_id: 'restored' })), + }, + ]) { + const { draft, apply } = fixture(); + draft.state.neighborId = 'b'; + draft.observe(current); + await draft.apply(); + expect(apply).not.toHaveBeenCalled(); + expect(draft.state.neighborId).toBe('b'); + expect(draft.state.base).toEqual(initial); + } +}); +it('exact private pair survives transport/state mutation and newer observations', async () => { + const { draft, apply, read, commandId } = fixture(); + draft.state.neighborId = 'b'; + draft.state.base!.siblings[0]!.name = 'forged'; + apply.mockImplementationOnce(async (p: ReorderTimelineSiblingCommand) => { + p.expected.siblings[0]!.name = 'transport-mutated'; + p.neighbor_id = 'mutated'; + throw new Error('Lost ACK'); + }); + await draft.apply(); + expect(draft.state.uncertain).toBe(true); + draft.observe({ ...initial, membership_revision_event_id: 'newer' }); + await draft.reload(); + expect(read).not.toHaveBeenCalled(); + await draft.apply(); + expect(apply.mock.calls[1]).toEqual([ + { node_id: 'a', neighbor_id: 'b', expected: initial }, + 'swap-1', + ]); + expect(commandId).toHaveBeenCalledTimes(1); + expect(draft.state.current?.membership_revision_event_id).toBe('newer'); + expect(draft.state.saved).toBe(true); +}); +it('only typed precommit refusal releases retry custody', async () => { + const { draft, apply } = fixture(); + draft.state.neighborId = 'b'; + apply.mockRejectedValueOnce(new Error('Reorder refused: stale', { cause: { kind: 'conflict' } })); + await draft.apply(); + expect(draft.state.uncertain).toBe(false); + expect(draft.state.neighborId).toBe('b'); + apply.mockRejectedValueOnce(new Error('Publication failed', { cause: { kind: 'conflict' } })); + await draft.apply(); + expect(draft.state.uncertain).toBe(true); +}); +it('discard requires successful same-node explicit read', async () => { + const { draft, read, apply } = fixture(); + draft.state.neighborId = 'b'; + read.mockRejectedValueOnce(new Error('offline')); + await draft.reload(); + expect(draft.state.neighborId).toBe('b'); + read.mockResolvedValueOnce({ ...initial, node_id: 'other' }); + await draft.reload(); + expect(draft.state.neighborId).toBe('b'); + await draft.reload(); + expect(draft.state.neighborId).toBe(null); + expect(apply).not.toHaveBeenCalled(); +}); +it('malformed ACK retains exact retry', async () => { + const { draft, apply } = fixture(); + draft.state.neighborId = 'b'; + apply.mockResolvedValueOnce(initial); + await draft.apply(); + expect(draft.state.uncertain).toBe(true); + expect(draft.state.saved).toBe(false); + await draft.apply(); + expect(draft.state.saved).toBe(true); + expect(apply.mock.calls[0]![1]).toEqual(apply.mock.calls[1]![1]); +}); +it('overlap and absent pair cannot be submitted', () => { + expect( + reordered( + { ...initial, siblings: initial.siblings.map((n) => ({ ...n, start_ms: 100 })) }, + 'b', + ), + ).toBeNull(); + expect(reordered(initial, 'missing')).toBeNull(); +}); +it('mirrors hierarchy adjacency rather than offering chronologically adjacent mismatched order', () => { + const r = { + ...initial, + siblings: [ + { ...initial.siblings[0]! }, + { ...initial.siblings[1]!, sort_order: 3 }, + { + node_id: 'c', + name: 'C', + start_ms: 600, + end_ms: 700, + sort_order: 2, + revision_event_id: null, + }, + ], + }; + expect(reordered(r, 'b')).toBeNull(); +}); +it('near-max safe ranges preserve exact millisecond arithmetic without unsafe intermediate sums', () => { + const r = { + ...initial, + siblings: [ + { ...initial.siblings[0]!, start_ms: 9007199254740881, end_ms: 9007199254740901 }, + { ...initial.siblings[1]!, start_ms: 9007199254740931, end_ms: 9007199254740950 }, + ], + }; + expect(reordered(r, 'b')?.siblings.map((n) => [n.start_ms, n.end_ms])).toEqual([ + [9007199254740881, 9007199254740900], + [9007199254740930, 9007199254740950], + ]); +}); + +it('confirmed reorder remains acknowledged when a post-save projection adopts a newer lock clock', async () => { + const { draft } = fixture(); + draft.state.neighborId = 'b'; + await draft.apply(); + draft.observe({ + ...draft.state.base!, + siblings: draft.state.base!.siblings.map((n) => ({ + ...n, + revision_event_id: n.node_id === 'a' ? 'later-lock' : n.revision_event_id, + })), + }); + expect(draft.state.saved).toBe(true); + expect(draft.state.uncertain).toBe(false); + expect(draft.state.base?.siblings.find((n) => n.node_id === 'a')?.revision_event_id).toBe( + 'later-lock', + ); + await draft.reload(); + expect(draft.state.saved).toBe(false); +}); diff --git a/ui/src/lib/components/editor/timelineReorderDraft.svelte.ts b/ui/src/lib/components/editor/timelineReorderDraft.svelte.ts new file mode 100644 index 00000000..4b77104c --- /dev/null +++ b/ui/src/lib/components/editor/timelineReorderDraft.svelte.ts @@ -0,0 +1,192 @@ +import type { + TimelineSiblingOrderRead, + ReorderTimelineSiblingCommand, +} from '$lib/timelineCommandTypes.js'; +const copy = (r: TimelineSiblingOrderRead): TimelineSiblingOrderRead => ({ + ...r, + siblings: r.siblings.map((n) => ({ ...n })), +}); +export const sameOrder = (a: TimelineSiblingOrderRead | null, b: TimelineSiblingOrderRead | null) => + !!a && + !!b && + a.node_id === b.node_id && + a.parent_id === b.parent_id && + a.level === b.level && + a.membership_revision_event_id === b.membership_revision_event_id && + a.siblings.length === b.siblings.length && + a.siblings.every((n, i) => { + const other = b.siblings[i]!; + return ( + n.node_id === other.node_id && + n.name === other.name && + n.start_ms === other.start_ms && + n.end_ms === other.end_ms && + n.sort_order === other.sort_order && + n.revision_event_id === other.revision_event_id + ); + }); +export function reordered( + read: TimelineSiblingOrderRead, + neighborId: string, +): TimelineSiblingOrderRead | null { + const result = copy(read); + const a = result.siblings.findIndex((n) => n.node_id === read.node_id); + const b = result.siblings.findIndex((n) => n.node_id === neighborId); + if (a < 0 || b < 0 || Math.abs(a - b) !== 1 || !read.parent_id) return null; + const first = result.siblings[Math.min(a, b)]!; + const second = result.siblings[Math.max(a, b)]!; + if (first.end_ms > second.start_ms || first.sort_order > second.sort_order) return null; + const hierarchy = [...result.siblings].sort( + (a, b) => + a.sort_order - b.sort_order || a.start_ms - b.start_ms || a.node_id.localeCompare(b.node_id), + ); + if (hierarchy[hierarchy.indexOf(first) + 1] !== second) return null; + if ( + !result.siblings.every( + (n) => [n.start_ms, n.end_ms].every(Number.isSafeInteger) && n.start_ms < n.end_ms, + ) + ) + return null; + const end = first.start_ms + (second.end_ms - second.start_ms); + const start = end + (second.start_ms - first.end_ms); + if (![start, end, first.start_ms, second.end_ms].every(Number.isSafeInteger)) return null; + const outerStart = first.start_ms, + outerEnd = second.end_ms; + if ( + result.siblings.some( + (n) => n !== first && n !== second && n.start_ms < outerEnd && n.end_ms > outerStart, + ) + ) + return null; + [first.sort_order, second.sort_order] = [second.sort_order, first.sort_order]; + first.end_ms = outerEnd; + first.start_ms = start; + second.start_ms = outerStart; + second.end_ms = end; + result.siblings.sort((a, b) => a.start_ms - b.start_ms || a.node_id.localeCompare(b.node_id)); + return result; +} +export function createTimelineReorderDraft(options: { + nodeId: string; + read: () => Promise; + apply: (p: ReorderTimelineSiblingCommand, id: string) => Promise; + commandId: () => string; +}) { + const state = $state({ + base: null as TimelineSiblingOrderRead | null, + current: null as TimelineSiblingOrderRead | null, + neighborId: null as string | null, + busy: false, + uncertain: false, + saved: false, + error: null as string | null, + }); + let base: TimelineSiblingOrderRead | null = null; + let submission: { payload: ReorderTimelineSiblingCommand; id: string } | null = null; + function useRead(read: TimelineSiblingOrderRead) { + base = copy(read); + state.base = copy(read); + state.current = copy(read); + state.neighborId = null; + state.saved = false; + state.error = null; + } + function observe(read: TimelineSiblingOrderRead | null | undefined) { + if (!read || read.node_id !== options.nodeId) return; + if (sameOrder(base, read)) state.current = copy(read); + else if (!submission && !state.busy && (!base || !state.neighborId)) { + const acknowledged = state.saved; + useRead(read); + state.saved = acknowledged; + } else state.current = copy(read); + } + async function reload() { + if (state.busy || submission) return; + const intent = state.neighborId; + state.busy = true; + state.error = null; + try { + const read = await options.read(); + if (!read || read.node_id !== options.nodeId) + throw new Error('Current siblings are unavailable; save the project and read again.'); + if (state.neighborId !== intent) + throw new Error('Reorder draft changed during the read; it was kept.'); + useRead(read); + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + } finally { + state.busy = false; + } + } + async function apply() { + if (state.busy || !base) return; + if (!submission) { + if (!sameOrder(base, state.current)) { + state.error = + 'Saved siblings changed. Your reorder draft is kept; discard it and read current siblings before applying.'; + return; + } + if (!state.neighborId || !reordered(base, state.neighborId)) return; + submission = { + id: options.commandId(), + payload: { node_id: options.nodeId, neighbor_id: state.neighborId, expected: copy(base) }, + }; + } + const accepted = submission; + state.busy = true; + state.error = null; + state.saved = false; + try { + const receipt = await options.apply( + { ...accepted.payload, expected: copy(accepted.payload.expected) }, + accepted.id, + ); + const expected = reordered(accepted.payload.expected, accepted.payload.neighbor_id); + if (!receipt || !expected) + throw new Error( + 'Reorder receipt unavailable. Retry this exact reorder to recover its result.', + ); + const pair = [accepted.payload.node_id, accepted.payload.neighbor_id]; + const clocks = receipt.siblings + .filter((n) => pair.includes(n.node_id)) + .map((n) => n.revision_event_id); + const normalized = copy(receipt); + for (const n of normalized.siblings) { + if (pair.includes(n.node_id)) + n.revision_event_id = expected.siblings.find( + (v) => v.node_id === n.node_id, + )!.revision_event_id; + } + if ( + clocks.length !== 2 || + !clocks[0] || + clocks[0] !== clocks[1] || + !sameOrder(normalized, expected) + ) + throw new Error( + 'Reorder receipt differs from the submitted pair. Retry the exact reorder.', + ); + if (sameOrder(state.current, accepted.payload.expected)) state.current = copy(receipt); + base = copy(receipt); + state.base = copy(receipt); + if (state.neighborId === accepted.payload.neighbor_id) state.neighborId = null; + submission = null; + state.uncertain = false; + state.saved = true; + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + const native = error instanceof Error ? error.cause : null; + const refused = + typeof native === 'object' && + native !== null && + 'kind' in native && + (native.kind === 'conflict' || native.kind === 'bad_request') && + state.error.startsWith('Reorder refused: '); + state.uncertain = !refused; + if (refused) submission = null; + } finally { + state.busy = false; + } + } + return { state, observe, reload, apply }; +} diff --git a/ui/src/lib/components/sidebar/bible/README.md b/ui/src/lib/components/sidebar/bible/README.md index 98f76c5e..1039539e 100644 --- a/ui/src/lib/components/sidebar/bible/README.md +++ b/ui/src/lib/components/sidebar/bible/README.md @@ -140,3 +140,12 @@ verification and failure, and recovery does not rebase them. An initial uncached detail failure also exposes Retry saved facts in place. Retry uses the same owned refresh, shows Loading while pending, and admits only the current node/session response; selecting another node retires the old read. + +## Exact authored freeform values + +`bibleGraphFieldDrafts.svelte.ts` forwards the exact submitted freeform string, +including leading/trailing whitespace and Unicode. Empty input retains the +existing explicit clear; whitespace-only input is authored text. A verified +matching acknowledgement may clear only its exact owned draft. Conflicts and +failed or retired reads retain it. `BibleGraphPartFields` and the public writer +remain the only Save route. diff --git a/ui/src/lib/components/sidebar/bible/bibleGraphFieldDrafts.svelte.ts b/ui/src/lib/components/sidebar/bible/bibleGraphFieldDrafts.svelte.ts index 96db6275..3cb186be 100644 --- a/ui/src/lib/components/sidebar/bible/bibleGraphFieldDrafts.svelte.ts +++ b/ui/src/lib/components/sidebar/bible/bibleGraphFieldDrafts.svelte.ts @@ -96,7 +96,7 @@ export function createBibleGraphFieldDrafts(options: { const owner = options.owner(), submitted = value(currentField), draft = state.drafts[field.id], - request = { submitted: submitted.trim() }; + request = { submitted }; requests[field.id] = request; const owned = () => options.owner() === owner && state.owner === owner && requests[field.id] === request; diff --git a/ui/src/lib/propagationProposalTypes.ts b/ui/src/lib/propagationProposalTypes.ts index f321b505..bbac191f 100644 --- a/ui/src/lib/propagationProposalTypes.ts +++ b/ui/src/lib/propagationProposalTypes.ts @@ -1,3 +1,4 @@ +import type { TimelineArcMembershipInput } from './timelineCommandTypes.js'; import type { StoryArcFieldInput } from './storyArcTypes.js'; import type { BibleGraphFieldKey, @@ -72,6 +73,8 @@ export interface PropagationProposal { ancestor_notes_previous?: TimelineNotesInput[] | null; ancestor_notes_current?: TimelineNotesInput[] | null; ancestor_notes_absence_revisions?: [string, string][] | null; + arc_membership_previous?: TimelineArcMembershipInput | null; + arc_membership_current?: TimelineArcMembershipInput | null; timeline_notes_previous?: TimelineNotesInput | null; timeline_notes_current?: TimelineNotesInput | null; arc_inputs?: StoryArcFieldInput[] | null; diff --git a/ui/src/lib/selectedNodeEditorTypes.ts b/ui/src/lib/selectedNodeEditorTypes.ts index c1eb185e..2408aa9b 100644 --- a/ui/src/lib/selectedNodeEditorTypes.ts +++ b/ui/src/lib/selectedNodeEditorTypes.ts @@ -1,5 +1,10 @@ import type { TimelineNotesInput } from './propagationProposalTypes.js'; -import type { TimelineNodeNameRead, TimelineNodeRangeRead } from './timelineCommandTypes.js'; +import type { + TimelineSiblingOrderRead, + TimelineArcMembershipInput, + TimelineNodeNameRead, + TimelineNodeRangeRead, +} from './timelineCommandTypes.js'; import type { BeatType, ContentStatus, NodeId, StoryLevel } from './timelineTypes.js'; export interface SelectedNodeEditorProjection { @@ -27,6 +32,8 @@ export interface SelectedNodeEditorNode { locked: boolean; range_read?: TimelineNodeRangeRead | null; name_read?: TimelineNodeNameRead | null; + order_read?: TimelineSiblingOrderRead | null; + arc_read?: TimelineArcMembershipInput | null; notes_read?: TimelineNotesInput | null; } diff --git a/ui/src/lib/stores/README.md b/ui/src/lib/stores/README.md index 96b41886..332036fb 100644 --- a/ui/src/lib/stores/README.md +++ b/ui/src/lib/stores/README.md @@ -89,6 +89,8 @@ continues. | `propagationProposalProjection.svelte.ts` | Projection cache and command bridge | Caches backend proposal list and replaces cache from command responses with stale-response guards. | Keep. | | `timelineTitleSession.svelte.ts` | Transient UI state | Exact title intent and immutable uncertain retry within the editor session. | Canonical names/history/dependencies remain backend-owned. | | `timelineNotesSession.svelte.ts` | Transient UI state | Per-clip exact Notes drafts, original owned author reads and immutable save retries within the existing editor session. Explicit Save uses the existing canonical Notes command; drafts never become agent inputs before acceptance. | +| `timelineReorderSession.svelte.ts` | Session-scoped per-clip sibling reorder intent, immutable retries and original owned receipts; canonical order and placement remain in SQLite/projections. | +| `timelineArcsSession.svelte.ts` | Session-scoped per-clip arc assignment drafts, immutable retries and owned save receipts; canonical membership remains in SQLite/projections. | | `timelinePlacementSession.svelte.ts` | Transient UI state | Captured per-node placement intent and immutable uncertain retry within the editor session. | Canonical placement and review remain backend/projection-owned. | | `scriptBlockEditSession.svelte.ts` | Transient UI state | Retains independent block text drafts, captured revisions and immutable pending edits across Script consumer replacement. | Keep session scoped; canonical text and propagation remain backend/projection-owned. | | `scriptBlockCreationSession.svelte.ts` | Transient UI state | Retains captured creation context, text and immutable pending payload/ID across Script consumer lifetimes; project activation replaces its owner. | Keep session scoped; canonical screenplay remains backend-owned and projection-backed. | @@ -294,3 +296,8 @@ Session-owner registration runs untracked because block components obtain it thr `timelineTitleSession` owns transient clip title intents in the current editor session; live projection refresh never silently rebases an authored pending title. + +The existing scriptBlockEditSession exposes active owners absent from the current +document projection, preserving exact text and original save retry. This is a +read of current session drafts, not a new recovery store or canonical ownership. +Session reset retires the collection as before; unrelated drafts stay independent. diff --git a/ui/src/lib/stores/bibleGraphNodeDetailProjection.client.test.ts b/ui/src/lib/stores/bibleGraphNodeDetailProjection.client.test.ts index 064f1f0b..baf4716e 100644 --- a/ui/src/lib/stores/bibleGraphNodeDetailProjection.client.test.ts +++ b/ui/src/lib/stores/bibleGraphNodeDetailProjection.client.test.ts @@ -103,7 +103,7 @@ for (const ordering of ['refresh-before-ack','ack-before-refresh']) { const editor=createBibleGraphFieldDrafts({verified:()=>true,owner:()=> 'Mara',fields:()=>current,save:()=>new Promise(resolve=>finish=resolve)}); const exact=' Blue draft — 雨.\\n\\n '; editor.update(current[0],exact); const base=editor.state.drafts.fact.base; - const saving=editor.save(current[0]); const ack={...current[0],value:{type:'text',value:exact.trim()}}; + const saving=editor.save(current[0]); const ack={...current[0],value:{type:'text',value:exact}}; if(ordering==='refresh-before-ack') { current=[{...current[0],value:{type:'text',value:'Green'}}]; editor.observe(); } finish(ack); await saving; if(ordering==='ack-before-refresh') { current=[{...current[0],value:{type:'text',value:'Green'}}]; editor.observe(); } @@ -119,9 +119,9 @@ for(const conflict of [false,true]) { const editor=createBibleGraphFieldDrafts({verified:()=>true,owner:()=> 'Mara',fields:()=>current,save:()=>new Promise(resolve=>finish=resolve)}); editor.update(current[0],' Blue — 雨. '); const saving=editor.save(current[0]); if(conflict) { current=[{...current[0],value:{type:'text',value:'Green'}}]; editor.observe(); } - current=[{...current[0],value:{type:'text',value:'Blue — 雨.'}}]; editor.observe(); finish(current[0]); await saving; + current=[{...current[0],value:{type:'text',value:' Blue — 雨. '}}]; editor.observe(); finish(current[0]); await saving; if(conflict) { assert.equal(editor.value(current[0]),' Blue — 雨. '); assert.equal(editor.changed(current[0]),true); } - else { assert.equal(editor.state.drafts.fact,undefined); assert.equal(editor.value(current[0]),'Blue — 雨.'); } + else { assert.equal(editor.state.drafts.fact,undefined); assert.equal(editor.value(current[0]),' Blue — 雨. '); } } `)); @@ -175,10 +175,10 @@ globalThis.read=async()=>projection('Mara','Red',1);const release=store.retainBi let acknowledge,refresh;const editor=createBibleGraphFieldDrafts({owner:()=> 'Mara',fields:()=>fields('Mara'),verified:()=>store.isBibleGraphNodeProjectionVerified(key('Mara')),save:()=>new Promise(resolve=>acknowledge=resolve)}); editor.update(fields('Mara')[0],' Blue draft — 雨. ');const saving=editor.save(fields('Mara')[0]); globalThis.read=()=>new Promise(resolve=>refresh=resolve);const reading=store.refreshOwnedBibleGraphNodeProjections(); -const ack=projection('Mara','Blue draft — 雨.',2);store.cacheNodeProjection(store.cacheKey(key('Mara')),ack);acknowledge(ack.payload.parts[0].fields[0]);await saving; -assert.equal(editor.state.drafts['Mara.tagline'],undefined);assert.equal(editor.value(fields('Mara')[0]),'Blue draft — 雨.');assert.equal(store.isBibleGraphNodeProjectionVerified(key('Mara')),false); +const ack=projection('Mara',' Blue draft — 雨. ',2);store.cacheNodeProjection(store.cacheKey(key('Mara')),ack);acknowledge(ack.payload.parts[0].fields[0]);await saving; +assert.equal(editor.state.drafts['Mara.tagline'],undefined);assert.equal(editor.value(fields('Mara')[0]),' Blue draft — 雨. ');assert.equal(store.isBibleGraphNodeProjectionVerified(key('Mara')),false); await editor.save(fields('Mara')[0]);assert.match(editor.state.errors['Mara.tagline'],/Verify saved facts/); -refresh(projection('Mara','Red',1));await assert.rejects(reading,/older revision/);assert.equal(store.isBibleGraphNodeProjectionVerified(key('Mara')),false);assert.equal(fields('Mara')[0].value.value,'Blue draft — 雨.'); +refresh(projection('Mara','Red',1));await assert.rejects(reading,/older revision/);assert.equal(store.isBibleGraphNodeProjectionVerified(key('Mara')),false);assert.equal(fields('Mara')[0].value.value,' Blue draft — 雨. '); globalThis.read=async()=>projection('Mara','Green',3);await store.refreshOwnedBibleGraphNodeProjections();editor.observe();assert.equal(editor.changed(fields('Mara')[0]),false);assert.equal(editor.value(fields('Mara')[0]),'Green');release(); `)); @@ -190,3 +190,29 @@ editor.update(current[0],'Old blue');const old=editor.save(current[0]);owner='El completions[0]({...current[0],value:{type:'text',value:'Old blue'}});await old;assert.equal(editor.value(current[0]),'New exact draft — 雨.');assert.equal(editor.state.saving.fact,true); current=[{...current[0],value:{type:'text',value:'New exact draft — 雨.'}}];completions[1](current[0]);await fresh;assert.equal(editor.state.drafts.fact,undefined);assert.equal(editor.state.saving.fact,false); `)); + +it('saves exactly entered freeform Bible text including whitespace and distinguishes empty clear', () => + run(` +for (const entered of [' Mara reveals the witness — 雨.\\n\\n ', ' \\n ', '']) { + let submitted; + let current=[{id:'exact.field',part_id:'profile',field_key:'tagline',value:{type:'text',value:'Original saved fact'}}]; + const editor=createBibleGraphFieldDrafts({verified:()=>true,owner:()=> 'Mara',fields:()=>current, + save:async(field,text)=>{submitted=text;current=[{...field,value:text?{type:'text',value:text}:null}];return current[0];}}); + editor.update(current[0],entered); + await editor.save(current[0]); + assert.equal(submitted,entered,'Save must preserve the exact authored text'); + assert.deepEqual(current[0].value,entered?{type:'text',value:entered}:null); + assert.equal(editor.value(current[0]),entered); + assert.equal(editor.state.drafts['exact.field'],undefined,'acknowledged exact draft retires'); +} +`)); + +it('retains exact author text when a transport acknowledges a trimmed different fact', () => + run(` +let current=[{id:'exact.field',part_id:'profile',field_key:'tagline',value:{type:'text',value:'Red'}}]; +const entered=' Blue — 雨. '; +const editor=createBibleGraphFieldDrafts({verified:()=>true,owner:()=> 'Mara',fields:()=>current, + save:async(field,text)=>{assert.equal(text,entered);current=[{...field,value:{type:'text',value:text.trim()}}];return current[0];}}); +editor.update(current[0],entered);await editor.save(current[0]); +assert.equal(editor.value(current[0]),entered);assert.equal(editor.state.drafts['exact.field'].baseText,'Red');assert.equal(editor.changed(current[0]),true); +`)); diff --git a/ui/src/lib/stores/scriptBlockEditSession.svelte.ts b/ui/src/lib/stores/scriptBlockEditSession.svelte.ts index c9f621b9..fc5c2ea2 100644 --- a/ui/src/lib/stores/scriptBlockEditSession.svelte.ts +++ b/ui/src/lib/stores/scriptBlockEditSession.svelte.ts @@ -68,6 +68,16 @@ export function getOrphanedScriptBlockRemovals(documentId: string, visibleBlockI }); } +// Canonical disappearance must not hide a still-owned edit or exact retry. +export function getMissingScriptBlockDrafts(documentId: string, visibleBlockIds: string[]) { + return Object.entries(drafts).flatMap(([key, draft]) => { + const [document, block] = JSON.parse(key) as [string, string]; + return document === documentId && draft?.state.editing && !visibleBlockIds.includes(block) + ? [draft] + : []; + }); +} + export function resetSessionScriptBlockEditDrafts(): void { generation += 1; drafts = Object.create(null) as EditDrafts; diff --git a/ui/src/lib/stores/timelineArcsSession.svelte.test.ts b/ui/src/lib/stores/timelineArcsSession.svelte.test.ts new file mode 100644 index 00000000..af1c5499 --- /dev/null +++ b/ui/src/lib/stores/timelineArcsSession.svelte.test.ts @@ -0,0 +1,108 @@ +import { expect, it, vi, beforeEach } from 'vitest'; +import { getSessionTimelineArcsDraft } from './timelineArcsSession.svelte.js'; +import { editorState, resetEditorState } from './editor.svelte.js'; +import { applyTimelineNodeArcsCommand } from './timelineRenderProjection.svelte.js'; +import { getSelectedNodeEditorProjection } from '$lib/projectionApi.js'; +import { refreshSelectedNodeEditorProjection } from './selectedNodeEditorProjection.svelte.js'; +import { invalidateScriptContext } from './scriptDocumentProjection.svelte.js'; +import type { SelectedNodeEditorNode } from '$lib/selectedNodeEditorTypes.js'; +vi.mock('./timelineRenderProjection.svelte.js', () => ({ applyTimelineNodeArcsCommand: vi.fn() })); +vi.mock('$lib/projectionApi.js', () => ({ getSelectedNodeEditorProjection: vi.fn() })); +vi.mock('./selectedNodeEditorProjection.svelte.js', () => ({ + refreshSelectedNodeEditorProjection: vi.fn(), +})); +vi.mock('./scriptDocumentProjection.svelte.js', () => ({ invalidateScriptContext: vi.fn() })); +const node: SelectedNodeEditorNode = { + node_id: 'A', + name: 'A', + level: 'Scene', + sort_order: 0, + start_ms: 1000, + end_ms: 2000, + notes: 'Saved A', + content_status: 'HasContent', + locked: false, + arc_read: { node_id: 'A', arc_ids: [], revision_event_id: null }, +}; +beforeEach(() => { + resetEditorState(); + vi.clearAllMocks(); + vi.mocked(applyTimelineNodeArcsCommand).mockResolvedValue({ + outcome: 'recorded', + projection: {} as never, + arc_read: { ...node.arc_read!, arc_ids: ['Accepted'], revision_event_id: 'ack' }, + }); + vi.mocked(refreshSelectedNodeEditorProjection).mockResolvedValue({} as never); +}); +it('retains two exact clip drafts through disposal/re-entry without saving; reopening clears custody', async () => { + const first = getSessionTimelineArcsDraft(node); + first.observe(node.arc_read); + first.state.arcIds = ['Draft A']; + const b = { ...node, node_id: 'B', arc_read: { ...node.arc_read!, node_id: 'B' } }; + const second = getSessionTimelineArcsDraft(b); + second.observe(b.arc_read); + second.state.arcIds = ['Draft B']; + expect(getSessionTimelineArcsDraft({ ...node })).toBe(first); + expect(first.state.arcIds).toEqual(['Draft A']); + expect(second.state.arcIds).toEqual(['Draft B']); + expect(applyTimelineNodeArcsCommand).not.toHaveBeenCalled(); + resetEditorState(); + const reopened = getSessionTimelineArcsDraft(node); + reopened.observe(node.arc_read); + expect(reopened).not.toBe(first); + expect(reopened.state.arcIds).toEqual([]); + await first.apply(); + expect(applyTimelineNodeArcsCommand).not.toHaveBeenCalled(); + expect(first.state.error).toContain('project changed'); +}); +it('keeps acknowledged save distinct from failed refresh and guards selection/session', async () => { + editorState.selectedNodeId = 'A'; + vi.mocked(refreshSelectedNodeEditorProjection).mockRejectedValueOnce( + new Error('Refresh unavailable'), + ); + const draft = getSessionTimelineArcsDraft(node); + draft.observe(node.arc_read); + draft.state.arcIds = ['Accepted']; + await draft.apply(); + expect(draft.state.saved).toBe(true); + expect(draft.state.uncertain).toBe(false); + expect(invalidateScriptContext).toHaveBeenCalledTimes(1); + const guard = vi.mocked(refreshSelectedNodeEditorProjection).mock.calls[0]![1]!; + expect(guard()).toBe(true); + editorState.selectedNodeId = 'B'; + expect(guard()).toBe(false); + resetEditorState(); + editorState.selectedNodeId = 'A'; + expect(guard()).toBe(false); +}); +it('reads a background owner without retargeting selection or writing', async () => { + editorState.selectedNodeId = 'B'; + vi.mocked(getSelectedNodeEditorProjection).mockResolvedValueOnce({ + payload: { node: { ...node, arc_read: { ...node.arc_read!, arc_ids: ['Current'] } } }, + } as never); + const draft = getSessionTimelineArcsDraft(node); + draft.observe(node.arc_read); + draft.state.arcIds = ['Unsaved']; + await draft.reload(); + expect(draft.state.arcIds).toEqual(['Current']); + expect(editorState.selectedNodeId).toBe('B'); + expect(refreshSelectedNodeEditorProjection).not.toHaveBeenCalled(); + expect(applyTimelineNodeArcsCommand).not.toHaveBeenCalled(); +}); +it('rejects old-project read completion without replacing retained text', async () => { + let resolve!: (value: never) => void; + vi.mocked(getSelectedNodeEditorProjection).mockReturnValueOnce( + new Promise((done) => { + resolve = done; + }), + ); + const draft = getSessionTimelineArcsDraft(node); + draft.observe(node.arc_read); + draft.state.arcIds = ['Unsaved']; + const pending = draft.reload(); + resetEditorState(); + resolve({ payload: { node } } as never); + await pending; + expect(draft.state.arcIds).toEqual(['Unsaved']); + expect(draft.state.error).toContain('project changed'); +}); diff --git a/ui/src/lib/stores/timelineArcsSession.svelte.ts b/ui/src/lib/stores/timelineArcsSession.svelte.ts new file mode 100644 index 00000000..26a58e9b --- /dev/null +++ b/ui/src/lib/stores/timelineArcsSession.svelte.ts @@ -0,0 +1,62 @@ +import { untrack } from 'svelte'; +import type { SelectedNodeEditorNode } from '$lib/selectedNodeEditorTypes.js'; +import { createCommandId } from '$lib/commandTransport.js'; +import { getSelectedNodeEditorProjection } from '$lib/projectionApi.js'; +import { createTimelineArcsDraft } from '$lib/components/editor/timelineArcsDraft.svelte.js'; +import { editorState, getEditorSessionGeneration } from './editor.svelte.js'; +import { refreshSelectedNodeEditorProjection } from './selectedNodeEditorProjection.svelte.js'; +import { invalidateScriptContext } from './scriptDocumentProjection.svelte.js'; +import { applyTimelineNodeArcsCommand } from './timelineRenderProjection.svelte.js'; + +let session = -1; +type ArcsDrafts = Record | undefined>; +let drafts = Object.create(null) as ArcsDrafts; + +/** Navigation retains each clip's intent; closing/reopening a project ends custody. */ +export function getSessionTimelineArcsDraft(node: SelectedNodeEditorNode) { + return untrack(() => { + const admitted = getEditorSessionGeneration(); + if (session !== admitted) { + session = admitted; + drafts = Object.create(null) as ArcsDrafts; + } + let draft = drafts[node.node_id]; + if (!draft) { + const assertCurrent = () => { + if (admitted !== getEditorSessionGeneration()) + throw new Error('The project changed before this arc assignment.'); + }; + draft = createTimelineArcsDraft({ + nodeId: node.node_id, + commandId: createCommandId, + async read() { + assertCurrent(); + // Independent read never retargets the selected projection cache. + const projection = await getSelectedNodeEditorProjection({ node_id: node.node_id }); + assertCurrent(); + const read = projection.payload.node; + return read?.node_id === node.node_id ? (read.arc_read ?? null) : null; + }, + async apply(payload, commandId) { + assertCurrent(); + const response = await applyTimelineNodeArcsCommand(payload, commandId); + assertCurrent(); + invalidateScriptContext(); + if (editorState.selectedNodeId === node.node_id) { + // Projection refresh failure cannot turn an owned save receipt into + // an uncertain acknowledgement. This cache reports its own errors. + void refreshSelectedNodeEditorProjection( + node.node_id, + () => + admitted === getEditorSessionGeneration() && + editorState.selectedNodeId === node.node_id, + ).catch(() => {}); + } + return response.arc_read ?? null; + }, + }); + drafts[node.node_id] = draft; + } + return draft; + }); +} diff --git a/ui/src/lib/stores/timelineRenderProjection.svelte.ts b/ui/src/lib/stores/timelineRenderProjection.svelte.ts index 7d985fd0..ee12792c 100644 --- a/ui/src/lib/stores/timelineRenderProjection.svelte.ts +++ b/ui/src/lib/stores/timelineRenderProjection.svelte.ts @@ -6,6 +6,8 @@ import { deleteTimelineNode, deleteTimelineRelationship, setTimelineNodeLock, + reorderTimelineSibling, + setTimelineNodeArcs, setTimelineNodeNotes, setTimelineNodeName, setTimelineNodeRange, @@ -24,6 +26,8 @@ import type { DeleteTimelineNodeCommand, DeleteTimelineRelationshipCommand, SetTimelineNodeLockCommand, + ReorderTimelineSiblingCommand, + SetTimelineNodeArcsCommand, SetTimelineNodeNotesCommand, SetTimelineNodeNameCommand, SetTimelineNodeRangeCommand, @@ -197,6 +201,17 @@ export async function applyTimelineNodeLockCommand( ); } +export async function applyTimelineNodeArcsCommand( + payload: SetTimelineNodeArcsCommand, + commandId?: CommandId, +): Promise { + return runTimelineProjectionRequest( + () => setTimelineNodeArcs(payload, commandId), + (result) => result.projection, + 'Failed to apply timeline arc assignment command', + ); +} + export async function applyTimelineNodeNotesCommand( payload: SetTimelineNodeNotesCommand, commandId?: CommandId, @@ -237,3 +252,14 @@ export function clearTimelineRenderProjection(): void { timelineRenderProjectionState.pending = false; timelineRenderProjectionState.error = undefined; } + +export async function applyTimelineSiblingReorderCommand( + payload: ReorderTimelineSiblingCommand, + commandId?: CommandId, +): Promise { + return runTimelineProjectionRequest( + () => reorderTimelineSibling(payload, commandId), + (result) => result.projection, + 'Failed to apply timeline sibling reorder command', + ); +} diff --git a/ui/src/lib/stores/timelineReorderSession.svelte.test.ts b/ui/src/lib/stores/timelineReorderSession.svelte.test.ts new file mode 100644 index 00000000..cc896377 --- /dev/null +++ b/ui/src/lib/stores/timelineReorderSession.svelte.test.ts @@ -0,0 +1,109 @@ +import { expect, it, vi, beforeEach } from 'vitest'; +import { getSessionTimelineReorderDraft } from './timelineReorderSession.svelte.js'; +import { editorState, resetEditorState } from './editor.svelte.js'; +import { applyTimelineSiblingReorderCommand } from './timelineRenderProjection.svelte.js'; +import { getSelectedNodeEditorProjection } from '$lib/projectionApi.js'; +import { refreshSelectedNodeEditorProjection } from './selectedNodeEditorProjection.svelte.js'; +import { invalidateScriptContext } from './scriptDocumentProjection.svelte.js'; +import { reordered } from '$lib/components/editor/timelineReorderDraft.svelte.js'; +import type { SelectedNodeEditorNode } from '$lib/selectedNodeEditorTypes.js'; +vi.mock('./timelineRenderProjection.svelte.js', () => ({ + applyTimelineSiblingReorderCommand: vi.fn(), +})); +vi.mock('$lib/projectionApi.js', () => ({ getSelectedNodeEditorProjection: vi.fn() })); +vi.mock('./selectedNodeEditorProjection.svelte.js', () => ({ + refreshSelectedNodeEditorProjection: vi.fn(), +})); +vi.mock('./scriptDocumentProjection.svelte.js', () => ({ invalidateScriptContext: vi.fn() })); +const node: SelectedNodeEditorNode = { + node_id: 'a', + name: 'A', + level: 'Scene', + sort_order: 1, + start_ms: 100, + end_ms: 200, + notes: 'A', + content_status: 'HasContent', + locked: true, + order_read: { + node_id: 'a', + parent_id: 'parent', + level: 'Scene', + membership_revision_event_id: null, + siblings: [ + { + node_id: 'a', + name: 'A', + start_ms: 100, + end_ms: 200, + sort_order: 1, + revision_event_id: null, + }, + { + node_id: 'b', + name: 'B', + start_ms: 300, + end_ms: 500, + sort_order: 2, + revision_event_id: null, + }, + ], + }, +}; +beforeEach(() => { + resetEditorState(); + vi.clearAllMocks(); + vi.mocked(applyTimelineSiblingReorderCommand).mockImplementation(async (p) => { + const r = reordered(p.expected, p.neighbor_id)!; + for (const n of r.siblings) n.revision_event_id = 'ack'; + return { outcome: 'recorded', projection: {} as never, order_read: r }; + }); + vi.mocked(refreshSelectedNodeEditorProjection).mockResolvedValue({} as never); +}); +it('retains per-clip reorder across remount and ends old-session command custody', async () => { + const draft = getSessionTimelineReorderDraft(node); + draft.observe(node.order_read); + draft.state.neighborId = 'b'; + const b = { ...node, node_id: 'b', order_read: { ...node.order_read!, node_id: 'b' } }; + const second = getSessionTimelineReorderDraft(b); + second.observe(b.order_read); + second.state.neighborId = 'a'; + expect(getSessionTimelineReorderDraft({ ...node })).toBe(draft); + expect(draft.state.neighborId).toBe('b'); + expect(second.state.neighborId).toBe('a'); + resetEditorState(); + expect(getSessionTimelineReorderDraft(node)).not.toBe(draft); + await draft.apply(); + expect(applyTimelineSiblingReorderCommand).not.toHaveBeenCalled(); + expect(draft.state.error).toContain('project changed'); +}); +it('locked manual structural reorder ACK survives refresh failure and late selection', async () => { + editorState.selectedNodeId = 'a'; + vi.mocked(refreshSelectedNodeEditorProjection).mockRejectedValueOnce(new Error('Refresh failed')); + const draft = getSessionTimelineReorderDraft(node); + draft.observe(node.order_read); + draft.state.neighborId = 'b'; + await draft.apply(); + expect(draft.state.saved).toBe(true); + expect(draft.state.uncertain).toBe(false); + expect(invalidateScriptContext).toHaveBeenCalledTimes(1); + const guard = vi.mocked(refreshSelectedNodeEditorProjection).mock.calls[0]![1]!; + expect(guard()).toBe(true); + editorState.selectedNodeId = 'b'; + expect(guard()).toBe(false); + resetEditorState(); + editorState.selectedNodeId = 'a'; + expect(guard()).toBe(false); +}); +it('background explicit read discards only its draft without retargeting current clip', async () => { + editorState.selectedNodeId = 'b'; + vi.mocked(getSelectedNodeEditorProjection).mockResolvedValueOnce({ payload: { node } } as never); + const draft = getSessionTimelineReorderDraft(node); + draft.observe(node.order_read); + draft.state.neighborId = 'b'; + await draft.reload(); + expect(draft.state.neighborId).toBeNull(); + expect(editorState.selectedNodeId).toBe('b'); + expect(refreshSelectedNodeEditorProjection).not.toHaveBeenCalled(); + expect(applyTimelineSiblingReorderCommand).not.toHaveBeenCalled(); +}); diff --git a/ui/src/lib/stores/timelineReorderSession.svelte.ts b/ui/src/lib/stores/timelineReorderSession.svelte.ts new file mode 100644 index 00000000..2b4d1576 --- /dev/null +++ b/ui/src/lib/stores/timelineReorderSession.svelte.ts @@ -0,0 +1,62 @@ +import { untrack } from 'svelte'; +import type { SelectedNodeEditorNode } from '$lib/selectedNodeEditorTypes.js'; +import { createCommandId } from '$lib/commandTransport.js'; +import { getSelectedNodeEditorProjection } from '$lib/projectionApi.js'; +import { createTimelineReorderDraft } from '$lib/components/editor/timelineReorderDraft.svelte.js'; +import { editorState, getEditorSessionGeneration } from './editor.svelte.js'; +import { refreshSelectedNodeEditorProjection } from './selectedNodeEditorProjection.svelte.js'; +import { invalidateScriptContext } from './scriptDocumentProjection.svelte.js'; +import { applyTimelineSiblingReorderCommand } from './timelineRenderProjection.svelte.js'; + +let session = -1; +type ReorderDrafts = Record | undefined>; +let drafts = Object.create(null) as ReorderDrafts; + +/** Navigation retains each clip's intent; closing/reopening a project ends custody. */ +export function getSessionTimelineReorderDraft(node: SelectedNodeEditorNode) { + return untrack(() => { + const admitted = getEditorSessionGeneration(); + if (session !== admitted) { + session = admitted; + drafts = Object.create(null) as ReorderDrafts; + } + let draft = drafts[node.node_id]; + if (!draft) { + const assertCurrent = () => { + if (admitted !== getEditorSessionGeneration()) + throw new Error('The project changed before this sibling reorder.'); + }; + draft = createTimelineReorderDraft({ + nodeId: node.node_id, + commandId: createCommandId, + async read() { + assertCurrent(); + // Independent read never retargets the selected projection cache. + const projection = await getSelectedNodeEditorProjection({ node_id: node.node_id }); + assertCurrent(); + const read = projection.payload.node; + return read?.node_id === node.node_id ? (read.order_read ?? null) : null; + }, + async apply(payload, commandId) { + assertCurrent(); + const response = await applyTimelineSiblingReorderCommand(payload, commandId); + assertCurrent(); + invalidateScriptContext(); + if (editorState.selectedNodeId === node.node_id) { + // Projection refresh failure cannot turn an owned save receipt into + // an uncertain acknowledgement. This cache reports its own errors. + void refreshSelectedNodeEditorProjection( + node.node_id, + () => + admitted === getEditorSessionGeneration() && + editorState.selectedNodeId === node.node_id, + ).catch(() => {}); + } + return response.order_read ?? null; + }, + }); + drafts[node.node_id] = draft; + } + return draft; + }); +} diff --git a/ui/src/lib/timelineCommandApi.ts b/ui/src/lib/timelineCommandApi.ts index 5ab6bf1d..66366866 100644 --- a/ui/src/lib/timelineCommandApi.ts +++ b/ui/src/lib/timelineCommandApi.ts @@ -8,6 +8,8 @@ import type { DeleteTimelineRelationshipCommand, SetTimelineNodeLockCommand, SetTimelineNodeNotesCommand, + ReorderTimelineSiblingCommand, + SetTimelineNodeArcsCommand, SetTimelineNodeNameCommand, SetTimelineNodeRangeCommand, SplitTimelineNodeCommand, @@ -23,6 +25,17 @@ export function setTimelinePlayhead(positionMs: number): Promise { + const command: CommandEnvelope = { + id: commandId, + payload, + }; + + return invokeDesktop('command_timeline_node_arcs', { command }); +} export function setTimelineNodeName( payload: SetTimelineNodeNameCommand, commandId = createCommandId(), @@ -159,3 +172,11 @@ export function deleteTimelineNode( return invokeDesktop('command_timeline_delete_node', { command }); } + +export function reorderTimelineSibling( + payload: ReorderTimelineSiblingCommand, + commandId = createCommandId(), +): Promise { + const command: CommandEnvelope = { id: commandId, payload }; + return invokeDesktop('command_timeline_sibling_reorder', { command }); +} diff --git a/ui/src/lib/timelineCommandTypes.ts b/ui/src/lib/timelineCommandTypes.ts index 15dc2553..03fbeb2c 100644 --- a/ui/src/lib/timelineCommandTypes.ts +++ b/ui/src/lib/timelineCommandTypes.ts @@ -1,3 +1,34 @@ +export interface TimelineSiblingPlacementRead { + node_id: string; + name: string; + start_ms: number; + end_ms: number; + sort_order: number; + revision_event_id: string | null; +} +export interface TimelineSiblingOrderRead { + node_id: string; + parent_id: string | null; + level: StoryLevel; + membership_revision_event_id: string | null; + siblings: TimelineSiblingPlacementRead[]; +} +export interface ReorderTimelineSiblingCommand { + node_id: string; + neighbor_id: string; + expected: TimelineSiblingOrderRead; +} +export interface TimelineArcMembershipInput { + node_id: string; + arc_ids: string[]; + revision_event_id: string | null; +} +export interface SetTimelineNodeArcsCommand { + node_id: string; + arc_ids: string[]; + expected: TimelineArcMembershipInput; +} + import type { TimelineNotesInput } from './propagationProposalTypes.js'; import type { BeatType, RelationshipId, RelationshipType, StoryLevel } from './timelineTypes.js'; import type { CommandOutcome, ProjectionEnvelope } from './projectionTypes.js'; @@ -92,6 +123,8 @@ export interface DeleteTimelineRelationshipCommand { } export interface TimelineCommandResponse { + order_read?: TimelineSiblingOrderRead | null; + arc_read?: TimelineArcMembershipInput | null; notes_read?: TimelineNotesInput | null; outcome: CommandOutcome; projection: ProjectionEnvelope;