blob: dfa5e78c6ce151a24dadb4c4cc4dd7f4f32c508c [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.
*/
//! Complete imports and Session publication share one atomic transaction.
//! Only publication creates the Session; imports never rewrite an old log fence.
use super::{PluginSession, origin, validate_id, validate_time};
use crate::{EventLog, StoreError};
use maka_agent_facts::artifact::content_digest;
use maka_agent_facts::event::Fact;
use maka_agent_facts::event::Invocation;
use maka_agent_facts::event::RuntimeEvent;
use maka_agent_facts::import::Record;
use maka_agent_facts::import::Source;
use serde::{Serialize, de::DeserializeOwned};
use sqlx::{Connection, SqliteConnection};
use std::time::{Duration, UNIX_EPOCH};
impl EventLog {
/// Published imports in one embedding-owned Session namespace. No foreign
/// Session configuration is decoded, and the cursor never broadens the scope.
pub async fn imported_sessions<T: DeserializeOwned + Send + 'static>(
&self,
prefix: &str,
after: Option<&str>,
limit: usize,
) -> Result<(Vec<super::SessionRecord<T>>, Option<String>), StoreError> {
self.validate_root()?;
if prefix.is_empty() || !(1..=16).contains(&limit) {
return Err(invalid("invalid import history query"));
}
if let Some(after) = after {
validate_id(after)?;
if !after.starts_with(prefix) {
return Err(invalid("foreign import cursor"));
}
}
let prefix = prefix.to_owned();
let after = after.map(str::to_owned);
self.connection.run(move |connection| Box::pin(async move {
let mut tx = connection.begin().await?;
let anchor: Option<i64> = if let Some(after) = &after {
Some(sqlx::query_scalar("SELECT created_at FROM session_imports WHERE session_id=? AND state='published'")
.bind(after).fetch_optional(&mut *tx).await?.ok_or(StoreError::SessionNotFound)?)
} else { None };
let ids: Vec<String> = sqlx::query_scalar(
"SELECT i.session_id FROM session_imports i JOIN session_control s ON s.id=i.session_id
WHERE instr(i.session_id,?1)=1 AND i.state='published'
AND (?2 IS NULL OR i.created_at<?2 OR (i.created_at=?2 AND i.session_id<?3))
AND NOT EXISTS (SELECT 1 FROM session_retirements r WHERE r.session_id=i.session_id AND r.remove_session=1)
ORDER BY i.created_at DESC,i.session_id DESC LIMIT ?4"
).bind(prefix).bind(anchor).bind(after).bind((limit+1) as i64).fetch_all(&mut *tx).await?;
let next = (ids.len()>limit).then(|| ids[limit-1].clone());
let mut records = Vec::with_capacity(limit);
for id in ids.into_iter().take(limit) {
records.push(super::read(&mut tx, &id).await?.ok_or(StoreError::SessionNotFound)?);
}
Ok((records, next))
})).await
}
/// The complete conversion and Session publication share one writer
/// transaction. A failed record leaves neither a partial log nor a Session.
/// The same bound origin validates an exact retry against canonical records.
pub async fn import_session<T: Serialize + DeserializeOwned + Send + 'static>(
&self,
owner: &PluginSession,
source: &Source,
configuration: &T,
records: Vec<Record>,
now: u64,
) -> Result<super::SessionRecord<T>, StoreError> {
self.validate_root()?;
owner.validate()?;
if owner.fingerprint.is_empty() {
return Err(invalid("invalid import fingerprint"));
}
source.validate().map_err(invalid)?;
validate_time(now)?;
if records.is_empty() || !records.iter().any(Record::is_conversation) {
return Err(invalid("invalid complete import"));
}
for record in &records {
record.validate().map_err(invalid)?;
}
let configuration = serde_json::to_string(configuration)?;
let source = serde_json::to_string(source)?;
let owner = owner.clone();
let commits = self.commits.clone();
self.connection.run(move |connection| Box::pin(async move {
let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
let previous: Option<(String, String, String, i64)> = sqlx::query_as(
"SELECT source_json, configuration, state, records FROM session_imports WHERE session_id=?"
).bind(&owner.session_id).fetch_optional(&mut *tx).await?;
if let Some((previous_source, previous_configuration, state, count)) = previous {
origin::check(&mut tx, &owner.session_id, Some(&owner)).await?;
if (previous_source, previous_configuration) != (source, configuration) || state != "published"
|| crate::sequence_number(count)? != records.len() as u64
{
return Err(StoreError::SessionConflict);
}
// append's exact-retry path also verifies original event digests.
append(&mut tx, &owner.session_id, records, true).await?;
return super::read(&mut tx, &owner.session_id).await?.ok_or(StoreError::SessionNotFound);
}
let occupied: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM session_control WHERE id=?1
UNION ALL SELECT 1 FROM session_retirements WHERE session_id=?1
UNION ALL SELECT 1 FROM session_history_copies WHERE session_id=?1
UNION ALL SELECT 1 FROM event_log WHERE event_session=?1)"
).bind(&owner.session_id).fetch_one(&mut *tx).await?;
if occupied { return Err(StoreError::SessionConflict); }
// Published retries above verify the original records before any new
// cross-record admission rule. Only a new complete import is checked.
{
let mut calls = std::collections::HashSet::new();
for record in &records {
if let maka_agent_facts::import::Content::ToolCall { call_id, .. } = &record.content
&& !calls.insert(call_id.as_str())
{
return Err(invalid("duplicate imported tool call identity"));
}
}
}
origin::insert(&mut tx, &owner).await?;
sqlx::query("INSERT INTO session_imports(session_id,source_json,configuration,created_at,state,records,bytes,conversation)
VALUES(?,?,?,?,'collecting',0,0,0)")
.bind(&owner.session_id).bind(source).bind(&configuration).bind(now as i64)
.execute(&mut *tx).await?;
let through = append(&mut tx, &owner.session_id, records, false).await?;
sqlx::query("UPDATE session_imports SET state='published' WHERE session_id=?")
.bind(&owner.session_id).execute(&mut *tx).await?;
super::insert(&mut tx, &owner.session_id, &owner.fingerprint, &configuration, now).await?;
let record = super::read(&mut tx, &owner.session_id).await?.ok_or(StoreError::SessionNotFound)?;
tx.commit().await.map_err(StoreError::CommitUnknown)?;
if let Some(sequence) = through { commits.send_replace(sequence); }
Ok(record)
})).await
}
}
async fn append(
connection: &mut SqliteConnection,
session: &str,
records: Vec<Record>,
replay: bool,
) -> Result<Option<u64>, StoreError> {
let (source, now): (String, i64) =
sqlx::query_as("SELECT source_json,created_at FROM session_imports WHERE session_id=?")
.bind(session)
.fetch_one(&mut *connection)
.await?;
let source: Source = serde_json::from_str(&source)?;
let mut last = None;
let mut bytes = 0;
for (index, record) in records.into_iter().enumerate() {
let event_id = identity(session, "message", &index.to_string());
let conversation = record.is_conversation();
let event = RuntimeEvent {
id: event_id.clone(),
invocation: Invocation {
session_id: session.to_owned(),
turn_id: identity(session, "turn", &record.source_turn_id),
run_id: identity(session, "run", &index.to_string()),
invocation_id: identity(session, "record", &index.to_string()),
},
recorded_at: UNIX_EPOCH + Duration::from_millis(now as u64),
fact: Fact::MessageImported {
source: source.clone(),
record: Box::new(record),
},
};
let json = serde_json::to_string(&event)?;
if replay {
let prior: (Option<String>, Option<String>) =
sqlx::query_as("SELECT event_json,body_digest FROM event_log WHERE event_id=?")
.bind(event_id)
.fetch_one(&mut *connection)
.await?;
let digest = prior
.0
.map(|body| content_digest(body.as_bytes()))
.or(prior.1);
if digest.as_deref() != Some(content_digest(json.as_bytes()).as_str()) {
return Err(StoreError::EventConflict);
}
continue;
}
bytes += json.len() as u64;
let inserted = sqlx::query("INSERT INTO event_log(event_id,invocation_id,kind,event_json) VALUES(?,?,'message_imported',?)")
.bind(&event.id).bind(&event.invocation.invocation_id).bind(json).execute(&mut *connection).await?;
last = Some(inserted.last_insert_rowid() as u64);
super::project_execution(&mut *connection, inserted.last_insert_rowid() as u64).await?;
crate::transcript::directory::record_source(
connection,
session,
inserted.last_insert_rowid() as u64,
)
.await?;
sqlx::query("UPDATE session_imports SET records=records+1,bytes=?,conversation=conversation+? WHERE session_id=?")
.bind(bytes as i64).bind(i64::from(conversation)).bind(session).execute(&mut *connection).await?;
}
Ok(last)
}
fn identity(session: &str, kind: &str, key: &str) -> String {
use sha2::{Digest, Sha256};
let mut hash = Sha256::new();
for part in ["maka.import.v1", session, kind, key] {
hash.update((part.len() as u64).to_be_bytes());
hash.update(part.as_bytes());
}
format!("import-{}", hex::encode(hash.finalize()))
}
fn invalid(message: &'static str) -> StoreError {
StoreError::InvalidTransition(message.into())
}