blob: c908af161bfa5cfdd487ccfc0bae1a8c959b66d9 [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::SessionRecord;
use crate::{
EventLog, StoreError,
context::{HistoryCut, history, safety},
sequence_number,
};
use maka_agent_facts::session::BranchOrigin;
use maka_agent_facts::session::CopyPurpose;
use maka_agent_facts::session::CopyState;
use maka_agent_facts::session::Lineage;
use serde::{Serialize, de::DeserializeOwned};
use sqlx::Connection;
pub use maka_agent_facts::session::CopyRequest as SessionCopy;
mod lifecycle;
pub use lifecycle::AbandonRevision;
pub(crate) use lifecycle::retain;
#[derive(Debug)]
pub enum SessionCopyResult<T> {
Committed(Box<SessionRecord<T>>),
SourceRevisionConflict { expected: u64, actual: u64 },
}
impl EventLog {
/// Recover the original request before re-reading a changed or deleted source.
/// Absence is not a reservation: a concurrent copy may still commit this ID.
pub async fn session_copy_receipt(
&self,
target: &str,
) -> Result<Option<maka_agent_facts::session::CopyReceipt>, StoreError> {
self.validate_root()?;
super::validate_id(target)?;
let target = target.to_owned();
self.connection
.run(move |connection| {
Box::pin(async move {
let row: Option<(String, String)> = sqlx::query_as(
"SELECT request_json, state FROM session_history_copies WHERE session_id=? AND bundle_digest IS NULL",
)
.bind(target)
.fetch_optional(connection)
.await?;
row.map(|(request, state)| {
Ok(maka_agent_facts::session::CopyReceipt {
request: serde_json::from_str(&request)?,
state: state.parse().map_err(super::invalid)?,
})
})
.transpose()
})
})
.await
}
/// Host-authorized history copy. The caller validates destination configuration
/// against the captured source revision; no execution or workspace is cloned.
/// Exact retries precede source CAS, including after a lost commit response.
pub async fn copy_session<T: Serialize + DeserializeOwned + Send + 'static>(
&self,
request: SessionCopy,
configuration: &T,
now: u64,
) -> Result<SessionCopyResult<T>, StoreError> {
self.copy_session_owned(request, configuration, now, None)
.await
}
/// The Host binds source access and destination creation authority separately.
/// Ownership, the complete creation fingerprint and history commit together.
pub async fn copy_plugin_session<T: Serialize + DeserializeOwned + Send + 'static>(
&self,
request: SessionCopy,
configuration: &T,
now: u64,
origin: super::PluginSession,
) -> Result<SessionCopyResult<T>, StoreError> {
origin.validate()?;
if origin.session_id != request.target_session_id {
return Err(super::invalid(
"copy target differs from its creation owner",
));
}
self.copy_session_owned(request, configuration, now, Some(origin))
.await
}
async fn copy_session_owned<T: Serialize + DeserializeOwned + Send + 'static>(
&self,
request: SessionCopy,
configuration: &T,
now: u64,
origin: Option<super::PluginSession>,
) -> Result<SessionCopyResult<T>, StoreError> {
self.validate_root()?;
super::validate_id(&request.source_session_id)?;
super::validate_id(&request.target_session_id)?;
super::validate_time(now)?;
if request.source_session_id == request.target_session_id
|| request.expected_source_revision == 0
|| request.expected_source_revision > super::MAX_SAFE_INTEGER
{
return Err(super::invalid("invalid Session copy identity or revision"));
}
let cut = match &request.purpose {
CopyPurpose::Branch {
turn_id: Some(turn),
..
} => HistoryCut::ThroughTurn(turn.clone()),
CopyPurpose::Branch { turn_id: None, .. } => HistoryCut::End,
CopyPurpose::EmptySideConversation => HistoryCut::Empty,
CopyPurpose::Revision { turn_id } => HistoryCut::BeforeTurn(turn_id.clone()),
};
if let HistoryCut::BeforeTurn(turn) | HistoryCut::ThroughTurn(turn) = &cut {
super::validate_id(turn)?;
}
let configuration = serde_json::to_string(configuration)?;
let encoded = serde_json::to_string(&request)?;
let fingerprint = origin.as_ref().map_or_else(
|| maka_agent_facts::artifact::content_digest(encoded.as_bytes()),
|origin| origin.fingerprint.clone(),
);
self.connection.run(move |connection| Box::pin(async move {
let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
let previous: Option<(String, Option<String>)> = sqlx::query_as(
"SELECT request_json,bundle_digest FROM session_history_copies WHERE session_id = ?",
).bind(&request.target_session_id).fetch_optional(&mut *tx).await?;
if let Some((previous, bundle_digest)) = previous {
if bundle_digest.is_some() { return Err(StoreError::SessionConflict); }
if previous != encoded { return Err(StoreError::SessionConflict); }
super::removal::require_mutable(&mut tx, &request.target_session_id).await?;
super::origin::check(&mut tx, &request.target_session_id, origin.as_ref()).await?;
let session = super::read(&mut tx, &request.target_session_id).await?
.ok_or(StoreError::SessionNotFound)?;
return Ok(SessionCopyResult::Committed(Box::new(session)));
}
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM session_control WHERE id = ?)",
).bind(&request.target_session_id).fetch_one(&mut *tx).await?;
if exists { return Err(StoreError::SessionConflict); }
let revision: Option<i64> = sqlx::query_scalar(
"SELECT revision FROM session_control WHERE id = ?",
).bind(&request.source_session_id).fetch_optional(&mut *tx).await?;
let actual = sequence_number(revision.ok_or(StoreError::SessionNotFound)?)?;
if actual != request.expected_source_revision {
return Ok(SessionCopyResult::SourceRevisionConflict {
expected: request.expected_source_revision, actual,
});
}
let manager: Option<(String, String)> = sqlx::query_as(
"SELECT package_id, scope_id FROM plugin_sessions WHERE session_id = ? AND managed = 1",
).bind(&request.source_session_id).fetch_optional(&mut *tx).await?;
if manager.is_some_and(|(package, scope)| origin.as_ref().is_none_or(|origin|
origin.creator.package() != package || String::from(origin.creator.scope().clone()) != scope
)) {
return Err(StoreError::ManagedSession);
}
let observed: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(sequence),0) FROM event_log")
.fetch_one(&mut *tx).await?;
let through = history::resolve_cut(&mut tx, &request.source_session_id, &cut, observed).await?;
safety::require_safe_through(&mut tx, &request.source_session_id, None, through).await?;
let lineage = lineage(&mut tx, &request).await?;
if let Some(origin) = &origin {
super::origin::insert(&mut tx, origin).await?;
}
super::insert(&mut tx, &request.target_session_id, &fingerprint, &configuration, now).await?;
let state = match request.purpose {
CopyPurpose::Revision { .. } => CopyState::Preparing,
_ => CopyState::Committed,
};
sqlx::query("INSERT INTO session_history_copies(session_id,source_session_id,source_revision,through_sequence,observed_through,request_json,lineage_json,state) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
.bind(&request.target_session_id).bind(&request.source_session_id)
.bind(actual as i64).bind(through as i64).bind(observed).bind(encoded).bind(serde_json::to_string(&lineage)?)
.bind(state.as_str())
.execute(&mut *tx).await?;
// Freeze the parent's current projection: its own archives over raw
// history, or an already inherited pin. Later pruning cannot change it.
sqlx::query(
"INSERT INTO session_history_members
SELECT ?1, e.sequence,
COALESCE((SELECT a.sequence FROM runtime_events a WHERE a.kind = 'tool_result_archived'
AND CAST(json_extract(a.event_json,'$.fact.placeholder.identity.runtime_event_id') AS TEXT) = e.event_id
AND json_extract(a.event_json,'$.invocation.session_id') = e.owner_session_id
AND a.sequence < ?2), e.archive_sequence)
FROM session_history_events e WHERE e.owner_session_id = ?3 AND e.sequence <= ?4",
).bind(&request.target_session_id).bind(observed.saturating_add(1))
.bind(&request.source_session_id).bind(through as i64).execute(&mut *tx).await?;
crate::artifacts::history::retain(&mut tx, &request.source_session_id, &request.target_session_id, now).await?;
crate::transcript::directory::copy(&mut tx, &request.source_session_id, &request.target_session_id, through).await?;
if let CopyPurpose::Revision { turn_id } = &request.purpose {
let retained = sqlx::query(
"INSERT INTO session_revision_sources
SELECT ?1, sequence FROM session_history_events
WHERE owner_session_id = ?2 AND sequence <= ?3
AND json_extract(event_json, '$.invocation.turn_id') = ?4
AND ((kind = 'invocation_opened'
AND json_extract(event_json, '$.fact.input.kind') = 'message'
AND json_array_length(event_json, '$.fact.input.source_messages') > 0)
OR (kind = 'message_steered' AND json_type(event_json, '$.fact.source') = 'object'))"
).bind(&request.target_session_id).bind(&request.source_session_id).bind(observed)
.bind(turn_id).execute(&mut *tx).await?;
if retained.rows_affected() == 0 { return Err(super::invalid("revision Turn has no editable input")); }
crate::artifacts::history::retain_revision(&mut tx, &request.source_session_id, &request.target_session_id, now).await?;
}
// History ownership is not execution admission: a fully archived
// source can seed a branch without becoming executable again.
super::removal::require_mutable(&mut tx, &request.source_session_id).await?;
retain(&mut tx, &request.source_session_id).await?;
let session = super::read(&mut tx, &request.target_session_id).await?
.ok_or(StoreError::SessionNotFound)?;
tx.commit().await.map_err(StoreError::CommitUnknown)?;
Ok(SessionCopyResult::Committed(Box::new(session)))
})).await
}
}
async fn lineage(
connection: &mut sqlx::SqliteConnection,
request: &SessionCopy,
) -> Result<Lineage, StoreError> {
let turn_id = match &request.purpose {
CopyPurpose::Branch { turn_id, .. } => {
return Ok(Lineage::Branch {
origin: BranchOrigin {
parent_session_id: request.source_session_id.clone(),
turn_id: turn_id.clone(),
},
});
}
CopyPurpose::EmptySideConversation => {
return Ok(Lineage::Branch {
origin: BranchOrigin {
parent_session_id: request.source_session_id.clone(),
turn_id: None,
},
});
}
CopyPurpose::Revision { turn_id } => turn_id,
};
let source: Option<String> =
sqlx::query_scalar("SELECT lineage_json FROM session_history_copies WHERE session_id = ?")
.bind(&request.source_session_id)
.fetch_optional(&mut *connection)
.await?;
let source: Option<Lineage> = source.map(|json| serde_json::from_str(&json)).transpose()?;
let root = match &source {
Some(Lineage::Revision {
root_session_id, ..
}) => root_session_id.clone(),
_ => request.source_session_id.clone(),
};
let index: i64 = sqlx::query_scalar(
"SELECT COALESCE(MAX(CAST(json_extract(lineage_json, '$.index') AS INTEGER)), 1) + 1
FROM session_history_copies WHERE json_extract(lineage_json, '$.kind') = 'revision'
AND CAST(json_extract(lineage_json, '$.root_session_id') AS TEXT) = ?",
)
.bind(&root)
.fetch_one(connection)
.await?;
if index as u64 > super::MAX_SAFE_INTEGER {
return Err(super::invalid("revision family exhausted"));
}
Ok(Lineage::Revision {
root_session_id: root,
parent_session_id: request.source_session_id.clone(),
turn_id: turn_id.clone(),
index: index as u64,
branch: source.as_ref().and_then(Lineage::branch).cloned(),
})
}