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
11 changes: 11 additions & 0 deletions crates/core/src/contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,17 @@ or persistent schema. A distinct Notes dependency on the existing TimelineNode
endpoint is independent of the screenplay-window selection epoch. Notes do not
infer fictional time; existing explicit targeted acceptance owns replacement.

Manual Notes saving reuses this same `TimelineNotesInput` as optional
`SetTimelineNodeNotesCommand.expected` and selected editor `notes_read`. Baseline
`revision_event_id: null` is a known canonical read; missing editor receipt is
unknown. The guarded writer checks exact node/text/owned clock atomically, including
edit/restore ABA, before changing current rows. Guardless legacy JSON is unchanged.
The optional command-response `notes_read` is the original command's owned
historical receipt, so an acknowledgement retry cannot adopt a later writer's
Notes or replace newer current text. Session drafts never enter prompt inputs
before explicit Save Notes; existing targeted acceptance still owns screenplay
replacement. No schema or second revision clock is introduced.

`StoryArcPromptField` restricts dependency identity to name, description and
arc_type. `StoryArcFieldInput` preserves exact supplied values and optional owned
field revisions; missing template history is explicitly unbound. Generation and
Expand Down
3 changes: 3 additions & 0 deletions crates/core/src/contracts/selected_node_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ pub struct SelectedNodeEditorNode {
pub range_read: Option<super::TimelineNodeRangeRead>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name_read: Option<super::TimelineNodeNameRead>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes_read: Option<super::TimelineNotesInput>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
Expand Down Expand Up @@ -143,6 +145,7 @@ impl From<&StoryNode> for SelectedNodeEditorNode {
locked: node.locked,
range_read: None,
name_read: None,
notes_read: None,
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/core/src/contracts/timeline_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ pub struct SetTimelineNodeLockCommand {
pub struct SetTimelineNodeNotesCommand {
pub node_id: NodeId,
pub notes: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected: Option<super::TimelineNotesInput>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
Expand Down
1 change: 1 addition & 0 deletions crates/server/examples/authoring_capture_fixture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ async fn prepare(state: &AppState) -> Result<serde_json::Value, Box<dyn std::err
command_service::set_timeline_node_notes(
state,
CommandEnvelope::new(SetTimelineNodeNotesCommand {
expected: None,
node_id: b.id,
notes: "Eli waits for Mara at the station. Continue the preceding screenplay.".into(),
}),
Expand Down
1 change: 1 addition & 0 deletions crates/server/examples/bible_fact_capture_fixture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ async fn prepare(state: &AppState) -> Result<serde_json::Value, Box<dyn std::err
command_service::set_timeline_node_notes(
state,
CommandEnvelope::new(SetTimelineNodeNotesCommand {
expected: None,
node_id: b.id,
notes: "Eli waits for Mara at the station. Continue the preceding screenplay.".into(),
}),
Expand Down
1 change: 1 addition & 0 deletions crates/server/examples/scene_order_capture_fixture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ async fn prepare(state: &AppState) -> Result<serde_json::Value, Box<dyn std::err
command_service::set_timeline_node_notes(
state,
CommandEnvelope::new(SetTimelineNodeNotesCommand {
expected: None,
node_id: b,
notes: "B waits at the station; consume only supplied screenplay evidence.".into(),
}),
Expand Down
1 change: 1 addition & 0 deletions crates/server/src/ai_generation_runtime_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ async fn set_fixture_notes(fixture: &Fixture, node_id: NodeId, notes: &str) {
crate::command_service::set_timeline_node_notes(
&fixture.state,
CommandEnvelope::new(SetTimelineNodeNotesCommand {
expected: None,
node_id,
notes: notes.into(),
}),
Expand Down
2 changes: 2 additions & 0 deletions crates/server/src/canonical_generation_service_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ async fn fixture() -> Fixture {
command_service::set_timeline_node_notes(
&state,
CommandEnvelope::new(SetTimelineNodeNotesCommand {
expected: None,
node_id: b,
notes: NOTES.into(),
}),
Expand Down Expand Up @@ -414,6 +415,7 @@ async fn delayed_http_after_new_scene_notes_aba_preserves_canonical_manual_scrip
command_service::set_timeline_node_notes(
&f.state,
CommandEnvelope::new(SetTimelineNodeNotesCommand {
expected: None,
node_id: f.node,
notes: notes.into(),
}),
Expand Down
2 changes: 2 additions & 0 deletions crates/server/src/child_plan_memory_service_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ async fn descendant_notes_aba_refuses_a_pending_plan_without_overwriting_manual_
crate::command_service::set_timeline_node_notes(
&fixture.state,
CommandEnvelope::new(SetTimelineNodeNotesCommand {
expected: None,
node_id: child,
notes: "Original manual beat notes".into(),
}),
Expand All @@ -473,6 +474,7 @@ async fn descendant_notes_aba_refuses_a_pending_plan_without_overwriting_manual_
crate::command_service::set_timeline_node_notes(
&fixture.state,
CommandEnvelope::new(SetTimelineNodeNotesCommand {
expected: None,
node_id: child,
notes,
}),
Expand Down
27 changes: 26 additions & 1 deletion crates/server/src/command_service_timeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ pub use crate::command_service_timeline_requests::{
#[derive(Debug, Serialize)]
pub struct TimelineCommandResponse {
outcome: RecordChangeOutcome,
#[serde(skip_serializing_if = "Option::is_none")]
notes_read: Option<eidetic_core::contracts::TimelineNotesInput>,
projection: ProjectionEnvelope<TimelineRenderProjection>,
}

Expand Down Expand Up @@ -89,6 +91,7 @@ async fn create_timeline_node_at_admission(
.map_err(map_timeline_command_error)?;
Ok::<_, BackendError>(TimelineCommandResponse {
outcome,
notes_read: None,
projection,
})
})
Expand Down Expand Up @@ -152,6 +155,7 @@ pub async fn set_timeline_node_name(
.map_err(map_timeline_command_error)?;
Ok::<_, BackendError>(TimelineCommandResponse {
outcome,
notes_read: None,
projection,
})
})
Expand Down Expand Up @@ -205,6 +209,7 @@ pub async fn set_timeline_node_range(
.map_err(map_timeline_command_error)?;
Ok::<_, BackendError>(TimelineCommandResponse {
outcome,
notes_read: None,
projection,
})
})
Expand Down Expand Up @@ -245,6 +250,7 @@ pub async fn set_timeline_node_lock(
.map_err(map_timeline_command_error)?;
Ok::<_, BackendError>(TimelineCommandResponse {
outcome,
notes_read: None,
projection,
})
})
Expand Down Expand Up @@ -282,12 +288,26 @@ pub async fn set_timeline_node_notes(
let outcome = timeline_command::record_set_timeline_node_notes_history(
&mut conn, &project, &command, 0,
)
.map_err(map_timeline_command_error)?;
.map_err(|error| {
let error = map_timeline_command_error(error);
match error {
BackendError::BadRequest(message) if command.payload.expected.is_some() => {
BackendError::bad_request(format!("Notes edit refused: {message}"))
}
BackendError::Conflict(message) if command.payload.expected.is_some() => {
BackendError::conflict(format!("Notes edit refused: {message}"))
}
error => error,
}
})?;
let notes_read = crate::timeline_notes_lineage::command_receipt(&conn, &command)
.map_err(map_history_error)?;
let projection =
timeline_render_projection_from_current_state(&conn, &project.timeline)
.map_err(map_timeline_command_error)?;
Ok::<_, BackendError>(TimelineCommandResponse {
outcome,
notes_read: Some(notes_read),
projection,
})
})
Expand Down Expand Up @@ -344,6 +364,7 @@ pub async fn delete_timeline_node(
.map_err(map_timeline_command_error)?;
Ok::<_, BackendError>(TimelineCommandResponse {
outcome,
notes_read: None,
projection,
})
})
Expand Down Expand Up @@ -394,6 +415,7 @@ pub async fn delete_timeline_relationship(
.map_err(map_timeline_command_error)?;
Ok::<_, BackendError>(TimelineCommandResponse {
outcome,
notes_read: None,
projection,
})
})
Expand Down Expand Up @@ -442,6 +464,7 @@ pub async fn create_timeline_relationship_from_core_command(
.map_err(map_timeline_command_error)?;
Ok::<_, BackendError>(TimelineCommandResponse {
outcome,
notes_read: None,
projection,
})
})
Expand Down Expand Up @@ -485,6 +508,7 @@ pub async fn apply_timeline_children(
.map_err(map_timeline_command_error)?;
Ok::<_, BackendError>(TimelineCommandResponse {
outcome,
notes_read: None,
projection,
})
})
Expand Down Expand Up @@ -584,6 +608,7 @@ pub async fn split_timeline_node_from_core_command(
.map_err(map_timeline_command_error)?;
Ok::<_, BackendError>(TimelineCommandResponse {
outcome,
notes_read: None,
projection,
})
})
Expand Down
3 changes: 3 additions & 0 deletions crates/server/src/projection_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,9 @@ fn load_selected_node_editor_at_path(
.as_mut()
.filter(|node| canonical_node_ids.contains(&node.node_id))
{
node.notes_read = Some(
crate::timeline_notes_lineage::capture(&tx, node.node_id).map_err(map_history_error)?,
);
node.name_read = Some(eidetic_core::contracts::TimelineNodeNameRead {
name: node.name.clone(),
revision_event_id: timeline_node_store::latest_name_event(&tx, node.node_id, None)
Expand Down
2 changes: 2 additions & 0 deletions crates/server/src/script_generation_target_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ fn manual_timeline_notes_edit_marks_its_saved_generated_screenplay_for_review()
&mut conn,
&project,
&CommandEnvelope::new(SetTimelineNodeNotesCommand {
expected: None,
node_id,
notes: notes.into(),
}),
Expand Down Expand Up @@ -212,6 +213,7 @@ fn notes_edit_and_restore_advances_target_revision_and_refuses_delayed_output()
&mut conn,
&project,
&CommandEnvelope::new(SetTimelineNodeNotesCommand {
expected: None,
node_id,
notes: notes.clone(),
}),
Expand Down
1 change: 1 addition & 0 deletions crates/server/src/state_autosave_custody_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ async fn autosave_custody_holds_snapshot_and_document_until_persistence_finishes
&mut conn,
project,
&CommandEnvelope::new(SetTimelineNodeNotesCommand {
expected: None,
node_id: node,
notes: notes.into(),
}),
Expand Down
1 change: 1 addition & 0 deletions crates/server/src/timeline_command_guard_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ fn fixture() -> (Connection, Project) {

fn notes_command(project: &Project) -> CommandEnvelope<SetTimelineNodeNotesCommand> {
CommandEnvelope::new(SetTimelineNodeNotesCommand {
expected: None,
node_id: project.timeline.nodes[0].id,
notes: "Intervening human edit".into(),
})
Expand Down
23 changes: 23 additions & 0 deletions crates/server/src/timeline_command_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,25 @@ pub(crate) fn record_set_timeline_node_notes_history(
&[revision],
|tx| {
crate::timeline_command_guard::validate_current_timeline(tx, &project.timeline)?;
if let Some(expected) = &command.payload.expected {
let current =
crate::timeline_notes_lineage::capture_excluding(tx, node.id, Some(event.id))?;
if expected != &current {
return Err(crate::history_store::HistoryStoreError::InvalidValue(
"Notes changed since this draft was read; keep the draft and read current Notes".into(),
));
}
if node.locked {
return Err(crate::history_store::HistoryStoreError::InvalidValue(
"Notes are locked".into(),
));
}
if command.payload.notes == current.notes {
return Err(crate::history_store::HistoryStoreError::InvalidValue(
"Notes are unchanged".into(),
));
}
}
timeline_node_store::upsert_nodes_in_transaction(tx, &next_timeline.nodes)
},
)?)
Expand Down Expand Up @@ -618,3 +637,7 @@ mod range_tests;
#[cfg(test)]
#[path = "timeline_title_command_tests.rs"]
mod title_tests;

#[cfg(test)]
#[path = "timeline_notes_draft_command_tests.rs"]
mod timeline_notes_draft_command_tests;
2 changes: 2 additions & 0 deletions crates/server/src/timeline_command_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ fn set_timeline_node_notes_updates_projection_status() {
let command = CommandEnvelope {
id: CommandId::new(),
payload: SetTimelineNodeNotesCommand {
expected: None,
node_id,
notes: "New outline".to_string(),
},
Expand All @@ -202,6 +203,7 @@ fn set_timeline_node_notes_rejects_unknown_node() {
let command = CommandEnvelope {
id: CommandId::new(),
payload: SetTimelineNodeNotesCommand {
expected: None,
node_id: NodeId::new(),
notes: "New outline".to_string(),
},
Expand Down
Loading
Loading