blob: d3288da235676e9e700dddb672f30816550e8d56 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::{ModelContextSource, latest_main, read, safety, selection::Selection};
use crate::{EventLog, StoreError, sequence_number};
use sqlx::{Connection, SqliteConnection};
/// Logical Turn cuts are resolved against canonical facts, not presentation rows.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(
tag = "kind",
content = "turn_id",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum HistoryCut {
Empty,
BeforeTurn(String),
ThroughTurn(String),
End,
}
/// Evidence for an owned history copy, never a continuation or execution grant.
#[derive(Debug)]
pub struct FrozenSessionHistory {
pub source_revision: u64,
/// Freezes effective archives separately from the retained conversation cut.
pub observed_through: u64,
pub context: ModelContextSource,
}
#[derive(Debug)]
pub enum HistoryCapture {
Captured(Box<FrozenSessionHistory>),
SourceRevisionConflict { expected: u64, actual: u64 },
}
impl EventLog {
/// Resolve the catalog CAS, complete logical Turn cut, effective archives and
/// bounded model rendering in one read snapshot. Persisting a copy must still
/// recheck this evidence in the transaction that publishes the destination.
pub async fn capture_session_history(
&self,
session: &str,
expected_revision: u64,
cut: HistoryCut,
max_events: usize,
max_bytes: usize,
) -> Result<HistoryCapture, StoreError> {
self.validate_root()?;
crate::sessions::validate_id(session)?;
if expected_revision == 0 || expected_revision > 9_007_199_254_740_991 {
return Err(invalid("source revision must be a positive safe integer"));
}
if let HistoryCut::BeforeTurn(turn) | HistoryCut::ThroughTurn(turn) = &cut {
crate::sessions::validate_id(turn)?;
}
let session = session.to_owned();
self.connection
.run(move |connection| {
Box::pin(async move {
let mut tx = connection.begin().await?;
let revision: Option<i64> =
sqlx::query_scalar("SELECT revision FROM session_control WHERE id = ?")
.bind(&session)
.fetch_optional(&mut *tx)
.await?;
let actual = sequence_number(revision.ok_or(StoreError::SessionNotFound)?)?;
if actual != expected_revision {
return Ok(HistoryCapture::SourceRevisionConflict {
expected: expected_revision,
actual,
});
}
let observed: i64 =
sqlx::query_scalar("SELECT COALESCE(MAX(sequence),0) FROM event_log")
.fetch_one(&mut *tx)
.await?;
let through = resolve_cut(&mut tx, &session, &cut, observed).await?;
safety::require_safe_through(&mut tx, &session, None, through).await?;
let selection = Selection::session(&session);
let archives_before = sequence_number(observed)?.saturating_add(1);
let latest = latest_main::read_selected_at(
&mut tx,
&selection,
through,
archives_before,
)
.await?;
let context = read::materialize_selected(
&mut tx,
&selection,
through,
archives_before,
max_events,
max_bytes,
latest,
)
.await?;
tx.commit().await?;
Ok(HistoryCapture::Captured(Box::new(FrozenSessionHistory {
source_revision: actual,
observed_through: sequence_number(observed)?,
context,
})))
})
})
.await
}
}
pub(crate) async fn resolve_cut(
connection: &mut SqliteConnection,
session: &str,
cut: &HistoryCut,
observed: i64,
) -> Result<u64, StoreError> {
let limit = match cut {
HistoryCut::Empty => 0,
HistoryCut::End => observed,
HistoryCut::BeforeTurn(turn) | HistoryCut::ThroughTurn(turn) => {
let (first, last): (Option<i64>, Option<i64>) = sqlx::query_as(
"SELECT MIN(sequence),MAX(sequence) FROM session_history_events
WHERE owner_session_id = ?1
AND json_extract(event_json,'$.invocation.turn_id') = ?2
AND sequence <= ?3
AND EXISTS(SELECT 1 FROM session_history_events opening
WHERE (opening.kind = 'message_imported' OR (opening.kind = 'invocation_opened'
AND json_extract(opening.event_json,'$.fact.input.kind') IN ('message','continuation','handoff')))
AND opening.owner_session_id = ?1
AND json_extract(opening.event_json,'$.invocation.turn_id') = ?2
AND opening.sequence <= ?3)",
)
.bind(session)
.bind(turn)
.bind(observed)
.fetch_one(&mut *connection)
.await?;
let (first, last) = first.zip(last).ok_or(StoreError::HistoryTurnMissing)?;
match cut {
HistoryCut::BeforeTurn(_) => first - 1,
HistoryCut::ThroughTurn(_) => last,
HistoryCut::Empty | HistoryCut::End => unreachable!(),
}
}
};
let crossing: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM session_history_events
WHERE owner_session_id = ?1 AND sequence <= ?2
GROUP BY json_extract(event_json,'$.invocation.turn_id')
HAVING MIN(sequence) <= ?3 AND MAX(sequence) > ?3)",
)
.bind(session)
.bind(observed)
.bind(limit)
.fetch_one(&mut *connection)
.await?;
if crossing {
return Err(StoreError::HistoryTurnSplit);
}
Selection::session(session)
.high_water(connection, sequence_number(limit)?)
.await
}
fn invalid(message: &str) -> StoreError {
StoreError::InvalidTransition(message.into())
}