Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions crates/core/src/contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 5 additions & 3 deletions crates/core/src/contracts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions crates/core/src/contracts/script_document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<super::TimelineArcMembershipInput>,
pub node_id: crate::timeline::node::NodeId,
pub start_ms: u64,
pub end_ms: u64,
Expand Down
4 changes: 4 additions & 0 deletions crates/core/src/contracts/script_impact_review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<super::TimelineArcMembershipInput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arc_membership_current: Option<super::TimelineArcMembershipInput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arc_description_applicability_previous: Option<Vec<super::StoryArcFieldInput>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down
6 changes: 6 additions & 0 deletions crates/core/src/contracts/selected_node_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ pub struct SelectedNodeEditorNode {
pub name_read: Option<super::TimelineNodeNameRead>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes_read: Option<super::TimelineNotesInput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arc_read: Option<super::TimelineArcMembershipInput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub order_read: Option<super::TimelineSiblingOrderRead>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
Expand Down Expand Up @@ -146,6 +150,8 @@ impl From<&StoryNode> for SelectedNodeEditorNode {
range_read: None,
name_read: None,
notes_read: None,
arc_read: None,
order_read: None,
}
}
}
Expand Down
49 changes: 49 additions & 0 deletions crates/core/src/contracts/timeline_command.rs
Original file line number Diff line number Diff line change
@@ -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<ArcId>,
pub revision_event_id: Option<super::ChangeEventId>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SetTimelineNodeArcsCommand {
pub node_id: NodeId,
pub arc_ids: Vec<ArcId>,
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<NodeId>,
pub level: StoryLevel,
pub membership_revision_event_id: Option<super::ChangeEventId>,
pub siblings: Vec<TimelineSiblingPlacementRead>,
}

#[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<super::ChangeEventId>,
}

#[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)]
Expand Down
9 changes: 9 additions & 0 deletions crates/core/src/timeline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions crates/core/src/timeline/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod node;
pub mod relationship;
pub mod sibling_reorder;
pub mod structure;
pub mod timing;
pub mod track;
Expand Down
150 changes: 150 additions & 0 deletions crates/core/src/timeline/sibling_reorder.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<_>>();
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;
Loading
Loading