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
2 changes: 1 addition & 1 deletion crates/core/src/contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ This directory defines host-agnostic command, event, revision, and projection co
| `bible_render_graph.rs` | Disposable Bevy-facing story-bible graph projection DTOs, deterministic layout helpers, and neighborhood indexes derived from canonical graph rows. |
| `context_influence.rs` | Timeline context stack with optional exact saved screenplay evidence and recorded evaluation/influence contracts. |
| `graph_proposal.rs` | Generic reviewable graph proposal contracts for agent-proposed bible nodes, fields, edges, and timeline-context links. |
| `script_document.rs` | Canonical script document, segment, block, span, lock, patch, and script projection contracts. |
| `script_document.rs` | Canonical script document, segment, block, span, lock, patch, projection and guarded manual edit contracts, including optional authored type and owned consumed-type receipts. |
| `script_impact_review.rs` | Explicit targeted preview request, bounded author-selected recall identities and captured screenplay/world-context evidence for revision-bound propagation proposals. |
| `semantic_dependency.rs` | Typed semantic relationships with optional source/target revision bindings for generation lineage. |
| `timeline_render.rs` | Timeline renderer projections, including core-derived gaps filtered by the renderer's minimum duration. |
Expand Down
30 changes: 29 additions & 1 deletion crates/core/src/contracts/script_document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,14 +197,39 @@ pub struct CreateScriptBlockCommand {
pub text: String,
}

/// A manual edit preserves the canonical block's kind, placement and ownership.
/// A manual edit preserves placement and ownership; an omitted kind preserves type.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EditScriptBlockCommand {
pub document_id: ScriptDocumentId,
pub block_id: ScriptBlockId,
pub expected_revision_event_id: super::ChangeEventId,
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_kind: Option<ScriptBlockKind>,
}

#[cfg(test)]
mod manual_type_wire_tests {
#[test]
fn omitted_type_keeps_legacy_wire_signature_and_invalid_types_are_rejected() {
let value = serde_json::json!({"document_id":"main","block_id":"A","expected_revision_event_id":uuid::Uuid::nil(),"text":"Exact"});
let command: super::EditScriptBlockCommand = serde_json::from_value(value.clone()).unwrap();
assert_eq!(command.block_kind, None);
assert_eq!(serde_json::to_value(command).unwrap(), value);
let mut invalid = value;
invalid["block_kind"] = serde_json::json!("invented");
assert!(serde_json::from_value::<super::EditScriptBlockCommand>(invalid).is_err());
}
#[test]
fn authored_type_can_use_the_existing_guarded_edit_operation() {
let value = serde_json::json!({
"document_id": "script.document.main", "block_id": "block.A",
"expected_revision_event_id": uuid::Uuid::nil(),
"text": " Exact authored line — 雨.\n\n ", "block_kind": "dialogue"
});
assert!(serde_json::from_value::<super::EditScriptBlockCommand>(value).is_ok());
}
}

/// Remove one exact saved block without changing its timeline placement.
Expand All @@ -227,6 +252,9 @@ pub struct ScriptContextBlock {
pub start_ms: u64,
pub end_ms: u64,
pub text: String,
/// Supplied type backed by this exact block revision; legacy absence is unknown.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_kind: Option<ScriptBlockKind>,
}

/// Complete continuity-window selection captured with the screenplay inputs.
Expand Down
3 changes: 3 additions & 0 deletions crates/core/src/contracts/script_impact_review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ pub struct ScriptImpactProposalBinding {
pub cause: ScriptImpactCause,
pub target_segment_revision_event_id: ChangeEventId,
pub script_inputs: Vec<ScriptContextBlock>,
/// Original consumed context from existing generation/accepted-preview receipts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script_previous_inputs: Option<Vec<ScriptContextBlock>>,
pub bible_context: ProjectionEnvelope<AiBibleContextProjection>,
#[serde(default)]
pub bible_inputs: Vec<super::BibleFieldInput>,
Expand Down
10 changes: 9 additions & 1 deletion crates/server/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ domain model in `eidetic-core`.
| `script_block_create_tests.rs` | Exact persistence/context, locks, replay, refusal, writer rollback and retiming SQLite regressions. |
| `script_block_create_impact_tests.rs` | Consumed-source append review/explicit acceptance, refreshed new-block lineage, later-append refusal, replay, rollback and historical retiming regressions; reconciled edits preserve newer text/review; explicitly continued drafts still reject later revisions and update canonical memory only on save. |
| `script_block_create_service_tests.rs` | Native AppState save/reopen/preview/retime and queued project replacement regressions; empty canonical schema is initialized before queuing because refusal precedes database access. |
| `script_block_edit.rs` | Manual text-only block edits with expected-revision and transaction-local lock validation, preserving server-owned placement and metadata. |
| `script_block_edit.rs` | Manual text and optional type edits with expected-revision and transaction-local lock validation; type-only writes preserve spans/provenance and placement. |
| `script_block_edit_tests.rs` | Save/reopen/history/context, stale/ABA refusal, lock refusal including locks added after a comparison read, and bounded continuity source regressions. |
| `manual_script_workflow_tests.rs` | Native AppState preview and shared generation-admission regression with a deliberately stale project mirror and no provider call. |
| `ai_temporal_context.rs` | Deterministic per-field fictional-time resolution before prompt construction; excludes future assertions and rejects same-time conflicts. |
Expand Down Expand Up @@ -710,3 +710,11 @@ command receipts preserve replay after later edits, deletion or ABA.
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.

## One owned block-type history delta

The shared `script_document_command::block_revision` records the canonical
`block_kind` once. Manual creation adds its sparse order field without adding
another kind delta; Change Review receives unique field keys and replay retains
the original revision. Missing-draft copies preserve the authored draft type,
while explicit creation remains the canonical Save authority.
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 @@ -847,6 +847,7 @@ async fn successful_persistence_keeps_captured_input_lineage_after_an_intervenin
crate::script_block_edit::apply_edit_script_block(
&mut conn,
&CommandEnvelope::new(eidetic_core::contracts::EditScriptBlockCommand {
block_kind: None,
document_id: captured.document_id.clone(),
block_id: captured.block_id.clone(),
expected_revision_event_id: captured.revision_event_id,
Expand Down
10 changes: 9 additions & 1 deletion crates/server/src/ai_script_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ pub(crate) fn load_script_context(
ORDER BY start_ms, sort_order, id LIMIT 2)
)
SELECT s.document_id, s.id, b.id, s.source_node_id, b.updated_event_id,
s.updated_event_id, s.start_ms, s.end_ms, b.text
s.updated_event_id, s.start_ms, s.end_ms, b.text,
(SELECT f.new_text FROM object_revisions r JOIN object_revision_fields f ON f.revision_id=r.id
WHERE r.object_kind='script_block' AND r.object_id=b.id AND r.change_event_id=b.updated_event_id
AND f.field_key='block_kind' AND f.new_type='text' AND f.new_text=b.block_kind LIMIT 1)
FROM script_segments s JOIN script_blocks b ON b.segment_id = s.id
JOIN script_documents d ON d.id = s.document_id
WHERE s.id IN (SELECT id FROM selected) AND b.deleted_event_id IS NULL AND d.deleted_event_id IS NULL
Expand All @@ -56,6 +59,7 @@ pub(crate) fn load_script_context(
row.get::<_, u64>(6)?,
row.get::<_, u64>(7)?,
row.get::<_, String>(8)?,
row.get::<_, Option<String>>(9)?,
))
},
)?;
Expand All @@ -70,6 +74,7 @@ pub(crate) fn load_script_context(
start_ms,
end_ms,
text,
block_kind,
) = row?;
Ok(ScriptContextBlock {
document_id: ScriptDocumentId::new(document)
Expand All @@ -90,6 +95,9 @@ pub(crate) fn load_script_context(
start_ms,
end_ms,
text,
block_kind: block_kind
.map(|kind| crate::script_store_codec::decode_block_kind(&kind))
.transpose()?,
})
})
.collect()
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 @@ -531,6 +531,7 @@ async fn delayed_regeneration_refuses_a_saved_human_edit_and_canonical_preview_s
command_service::edit_script_block(
&f.state,
CommandEnvelope::new(EditScriptBlockCommand {
block_kind: None,
document_id: doc.document.id,
block_id: block.block.id.clone(),
expected_revision_event_id: block.revision_event_id.unwrap(),
Expand Down Expand Up @@ -603,6 +604,7 @@ async fn autosave_interleaving_after_public_edit_signature_read_preserves_manual
.unwrap();
let manual = "Exact human edit across an autosave commit.\n\n";
let command = CommandEnvelope::new(EditScriptBlockCommand {
block_kind: None,
document_id: doc.document.id,
block_id: block.block.id.clone(),
expected_revision_event_id: block.revision_event_id.unwrap(),
Expand Down
3 changes: 3 additions & 0 deletions crates/server/src/child_plan_memory_service_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ async fn fixture() -> Fixture {
crate::command_service::edit_script_block(
&state,
CommandEnvelope::new(EditScriptBlockCommand {
block_kind: None,
document_id: ScriptDocumentId::new("script.document.main").unwrap(),
block_id: block.block.id.clone(),
expected_revision_event_id: block.revision_event_id.unwrap(),
Expand Down Expand Up @@ -126,6 +127,7 @@ async fn edit(fixture: &Fixture, text: &str) {
crate::command_service::edit_script_block(
&fixture.state,
CommandEnvelope::new(EditScriptBlockCommand {
block_kind: None,
document_id: ScriptDocumentId::new("script.document.main").unwrap(),
block_id: fixture.block.clone(),
expected_revision_event_id: block.revision_event_id.unwrap(),
Expand Down Expand Up @@ -572,6 +574,7 @@ async fn unselected_distant_screenplay_text_edit_does_not_stale_the_pending_plan
crate::command_service::edit_script_block(
&fixture.state,
CommandEnvelope::new(EditScriptBlockCommand {
block_kind: None,
document_id: ScriptDocumentId::new("script.document.main").unwrap(),
block_id: block.block.id.clone(),
expected_revision_event_id: block.revision_event_id.unwrap(),
Expand Down
1 change: 1 addition & 0 deletions crates/server/src/manual_script_workflow_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ async fn manual_edit_reaches_preview_and_generation_admission_with_a_stale_proje
let saved = crate::command_service::edit_script_block(
&state,
CommandEnvelope::new(EditScriptBlockCommand {
block_kind: None,
document_id: seed.payload.document_id,
block_id,
expected_revision_event_id: seeded.payload.segments[0].blocks[0]
Expand Down
14 changes: 12 additions & 2 deletions crates/server/src/prompt_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,21 @@ pub(crate) fn append_script_context(
"CANONICAL SCREENPLAY CONTEXT (authored text; world interpretations require review):\n",
);
for block in blocks {
let kind = block
.block_kind
.as_ref()
.map(|kind| {
format!(
" block_type={}",
crate::script_store_codec::encode_block_kind(kind)
)
})
.unwrap_or_default();
user.push_str(&format!(
"--- document={} segment={} block={} block_revision={} segment_revision={} presentation={}..{}ms ---\n{}\n\n",
"--- document={} segment={} block={} block_revision={} segment_revision={} presentation={}..{}ms{} ---\n{}\n\n",
block.document_id.as_str(), block.segment_id.as_str(), block.block_id.as_str(),
block.revision_event_id.0, block.segment_revision_event_id.0,
block.start_ms, block.end_ms, block.text,
block.start_ms, block.end_ms, kind, block.text,
));
}
}
Expand Down
16 changes: 5 additions & 11 deletions crates/server/src/script_block_create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use rusqlite::Connection;

use crate::history_store::{self, HistoryStoreError, RecordChangeOutcome};
use crate::script_document_command::{self, ScriptDocumentCommandError};
use crate::{script_store, script_store_codec, timeline_node_store};
use crate::{script_store, timeline_node_store};

const COMMAND_TYPE: &str = "script.create_block";

Expand Down Expand Up @@ -94,19 +94,13 @@ pub(crate) fn apply_create_script_block(
}),
)));
revisions.push(
script_document_command::block_revision(&block, None, event.id)
.with_field(FieldDelta::new(
"block_kind",
None,
Some(FieldValue::Text(
script_store_codec::encode_block_kind(&block.block_kind).into(),
)),
))
.with_field(FieldDelta::new(
script_document_command::block_revision(&block, None, event.id).with_field(
FieldDelta::new(
"sort_order",
None,
Some(FieldValue::Integer(i64::from(block.sort_order))),
)),
),
),
);
let mut span_revision = script_document_command::span_revision(&span, event.id)
.with_field(FieldDelta::new(
Expand Down
8 changes: 8 additions & 0 deletions crates/server/src/script_block_create_impact_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ fn consumed_source_append_requires_review_and_explicit_update_refreshes_all_memb
.needs_review
);
let edit = CommandEnvelope::new(EditScriptBlockCommand {
block_kind: None,
document_id: a.document_id,
block_id: source.blocks[1].block.id.clone(),
expected_revision_event_id: source.blocks[1].revision_event_id.unwrap(),
Expand Down Expand Up @@ -392,6 +393,7 @@ fn reconciled_manual_edit_preserves_later_exact_text_and_consumed_source_review(
.unwrap()[0]
.clone();
let original = CommandEnvelope::new(EditScriptBlockCommand {
block_kind: None,
document_id: a.document_id.clone(),
block_id: first.block_id.clone(),
expected_revision_event_id: first.revision_event_id,
Expand Down Expand Up @@ -422,6 +424,7 @@ fn reconciled_manual_edit_preserves_later_exact_text_and_consumed_source_review(
.find(|block| block.block.id == first.block_id)
.unwrap();
let newer = CommandEnvelope::new(EditScriptBlockCommand {
block_kind: None,
expected_revision_event_id: current.revision_event_id.unwrap(),
text: " Later canonical author text — 雪\n\n ".into(),
..original.payload.clone()
Expand Down Expand Up @@ -483,12 +486,14 @@ fn retained_draft_from_an_explicit_read_still_refuses_later_edits_then_updates_c
.clone();
let draft_text = " Exact retained draft — 雨\n\n ";
let stale = CommandEnvelope::new(EditScriptBlockCommand {
block_kind: None,
document_id: a.document_id.clone(),
block_id: input.block_id.clone(),
expected_revision_event_id: input.revision_event_id,
text: draft_text.into(),
});
let writer = CommandEnvelope::new(EditScriptBlockCommand {
block_kind: None,
text: "Another author's current text".into(),
..stale.payload.clone()
});
Expand All @@ -507,10 +512,12 @@ fn retained_draft_from_an_explicit_read_still_refuses_later_edits_then_updates_c
.unwrap();
assert_eq!(current.block.text, writer.payload.text);
let continued = CommandEnvelope::new(EditScriptBlockCommand {
block_kind: None,
expected_revision_event_id: current.revision_event_id.unwrap(),
..stale.payload.clone()
});
let later = CommandEnvelope::new(EditScriptBlockCommand {
block_kind: None,
text: "Same text, later revision".into(),
..continued.payload.clone()
});
Expand All @@ -535,6 +542,7 @@ fn retained_draft_from_an_explicit_read_still_refuses_later_edits_then_updates_c
.find(|block| block.block.id == input.block_id)
.unwrap();
let confirmed = CommandEnvelope::new(EditScriptBlockCommand {
block_kind: None,
expected_revision_event_id: reread.revision_event_id.unwrap(),
..stale.payload.clone()
});
Expand Down
74 changes: 74 additions & 0 deletions crates/server/src/script_block_create_service_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,79 @@ use crate::state::{AppState, ServerEvent};
use crate::{command_service, project_service, projection_service, script_store, sqlite};
use eidetic_core::contracts::*;

#[tokio::test]
async fn public_type_only_edit_publishes_exact_canonical_prompt_memory_and_replays_without_events()
{
let (project, first, second) = crate::script_block_create::tests::story_project();
let directory = crate::persistence::default_project_dir()
.join(format!("manual-type-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&directory).unwrap();
let path = directory.join("project.db");
crate::persistence::save_project(&project, &path, None)
.await
.unwrap();
let state = AppState::new().await;
project_service::replace_active_project(&state, project, path.clone());
let exact = " Exact authored line — 雨.\n\n ";
let created = command_service::create_script_block(
&state,
CommandEnvelope::new(CreateScriptBlockCommand {
document_id: ScriptDocumentId::new("script.document.main").unwrap(),
source_node_id: first,
expected_start_ms: 1000,
expected_end_ms: 2000,
block_kind: ScriptBlockKind::Action,
text: exact.into(),
}),
)
.await
.unwrap();
let block = &created.projection.payload.segments[0].blocks[0];
let edit = CommandEnvelope::new(EditScriptBlockCommand {
document_id: created.projection.payload.document.id.clone(),
block_id: block.block.id.clone(),
expected_revision_event_id: block.revision_event_id.unwrap(),
text: exact.into(),
block_kind: Some(ScriptBlockKind::Dialogue),
});
let mut events = state.events_tx.subscribe();
let changed = command_service::edit_script_block(&state, edit.clone())
.await
.unwrap();
assert_eq!(
changed.projection.payload.segments[0].blocks[0].block.text,
exact
);
assert_eq!(
changed.projection.payload.segments[0].blocks[0]
.block
.block_kind,
ScriptBlockKind::Dialogue
);
assert_eq!(
changed.projection.payload.segments[0].blocks[0].spans,
block.spans
);
assert!(matches!(
events.try_recv().unwrap(),
ServerEvent::ScriptChanged
));
let preview = crate::ai_service::preview_ai_context(&state, second.0)
.await
.unwrap();
assert!(preview.user.contains("block_type=dialogue"));
assert!(preview.user.contains(exact));
assert_eq!(
command_service::edit_script_block(&state, edit)
.await
.unwrap()
.outcome,
crate::history_store::RecordChangeOutcome::AlreadyRecorded
);
assert!(events.try_recv().is_err());
std::fs::remove_dir_all(directory).unwrap();
}

#[tokio::test]
async fn native_manual_creation_edit_save_reopen_and_retime_reach_exact_prompt_memory() {
let (project, first, second) = crate::script_block_create::tests::story_project();
Expand Down Expand Up @@ -43,6 +116,7 @@ async fn native_manual_creation_edit_save_reopen_and_retime_reach_exact_prompt_m
command_service::edit_script_block(
&state,
CommandEnvelope::new(EditScriptBlockCommand {
block_kind: None,
document_id: command.payload.document_id.clone(),
block_id: block.block.id.clone(),
expected_revision_event_id: block.revision_event_id.unwrap(),
Expand Down
Loading
Loading