blob: 963cffbf90b4d1cb6291ff686abebffad8fef6a4 [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.
*/
//! Durable session control metadata. Execution history remains in runtime_events.
mod activity;
mod admission;
mod catalog;
pub(crate) use admission::retain;
pub(crate) mod copy;
pub use copy::{AbandonRevision, SessionCopy, SessionCopyResult};
mod execution;
mod import;
mod material;
pub use material::MaterialCollection;
mod metadata;
mod origin;
mod processes;
pub(crate) mod removal;
pub use origin::PluginSession;
pub use removal::{
RemovalAuthority, RemovalPlan, RemoveFamilyResult, SessionRemovalResult, SessionRetirement,
};
pub(crate) mod read_state;
pub(crate) use activity::{initialize_execution, project_execution, register_functions};
pub use execution::{CatalogMessage, SessionExecution, SessionExecutionState};
pub(crate) use execution::{advance_revision, has_active_invocation};
pub use metadata::SessionMutation;
pub use read_state::SessionReadState;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use sqlx::{Connection, Row, SqliteConnection};
use crate::{EventLog, StoreError};
pub const MAX_SESSION_PAGE: usize = 32;
const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct SessionRecord<T> {
pub id: String,
pub revision: u64,
pub created_at: u64,
pub updated_at: u64,
pub archived: bool,
pub configuration: T,
/// Exact stored configuration basis, independent of execution-driven revision.
#[serde(skip)]
pub configuration_digest: String,
#[serde(default)]
pub read_state: SessionReadState,
/// Bounded execution projection read in the same snapshot as metadata.
#[serde(skip)]
pub execution: Option<SessionExecution>,
/// Message activity does not imply a local execution (for example, imported history).
#[serde(skip)]
pub last_message: Option<CatalogMessage>,
/// Oldest unresolved interaction, read from canonical facts in the same snapshot.
#[serde(skip)]
pub pending_interaction_since: Option<u64>,
pub lineage: Option<Box<maka_agent_facts::session::Lineage>>,
pub copy_state: Option<maka_agent_facts::session::CopyState>,
}
#[derive(Clone, Debug, Serialize)]
pub enum CatalogScope {
Profile,
Session(String),
Workspace(String),
/// Server-owned namespace. This is a data filter, never authorization.
IdPrefix(String),
/// One owner's catalog query. Every selection remains within its namespace.
/// This is a data filter; the caller still supplies the original authority.
Owned {
id_prefix: String,
selection: CatalogSelection,
title_query: String,
},
}
#[derive(Clone, Debug, Serialize)]
pub enum CatalogSelection {
All,
Project(uuid::Uuid),
Workspace {
/// The original owned anchor also participates in continuation identity.
session_id: String,
identity: maka_agent_facts::execution::WorkspaceIdentity,
},
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct CatalogFilter {
pub archived_only: bool,
pub include_archived: bool,
pub pending_only: bool,
}
#[derive(Clone, Debug)]
pub struct SessionPage<T> {
pub revision: String,
pub sessions: Vec<SessionRecord<T>>,
/// All unresolved-interaction sessions in this scope, independent of page size.
pub pending_session_count: u64,
pub next_cursor: Option<String>,
pub previous_cursor: Option<String>,
}
/// Keyset position within one revisioned session catalog.
#[derive(Clone, Copy, Debug)]
pub enum SessionPosition<'a> {
Start,
/// Inclusive anchor for a fresh snapshot, with backward fallback at the tail.
At(&'a str),
After(&'a str),
Before(&'a str),
}
impl<'a> From<Option<&'a str>> for SessionPosition<'a> {
fn from(cursor: Option<&'a str>) -> Self {
cursor.map_or(Self::Start, Self::After)
}
}
impl EventLog {
pub async fn probe_session_create<T: DeserializeOwned + Send + 'static>(
&self,
id: &str,
fingerprint: &str,
) -> Result<Option<SessionRecord<T>>, StoreError> {
self.validate_root()?;
validate_id(id)?;
let (id, fingerprint) = (id.to_owned(), fingerprint.to_owned());
self.connection
.run(move |connection| {
Box::pin(async move {
let mut tx = connection.begin().await?;
probe(&mut tx, &id, &fingerprint).await
})
})
.await
}
/// Configuration is opaque here; callers must validate its domain semantics.
pub async fn create_session<T: Serialize + DeserializeOwned + Send + 'static>(
&self,
id: &str,
fingerprint: &str,
configuration: &T,
now: u64,
) -> Result<SessionRecord<T>, StoreError> {
self.create_session_owned(id, fingerprint, configuration, now, None)
.await
}
/// Creator identity, optional management and Session creation share one transaction.
/// Only Host admission supplies the bound package/scope.
pub async fn create_plugin_session<T: Serialize + DeserializeOwned + Send + 'static>(
&self,
claim: &PluginSession,
configuration: &T,
now: u64,
) -> Result<SessionRecord<T>, StoreError> {
claim.validate()?;
self.create_session_owned(
&claim.session_id,
&claim.fingerprint,
configuration,
now,
Some(claim.clone()),
)
.await
}
async fn create_session_owned<T: Serialize + DeserializeOwned + Send + 'static>(
&self,
id: &str,
fingerprint: &str,
configuration: &T,
now: u64,
origin: Option<PluginSession>,
) -> Result<SessionRecord<T>, StoreError> {
self.validate_root()?;
validate_id(id)?;
validate_time(now)?;
let configuration = serde_json::to_string(configuration)?;
let (id, fingerprint) = (id.to_owned(), fingerprint.to_owned());
self.connection
.run(move |connection| {
Box::pin(async move {
let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
if let Some(record) = probe(&mut tx, &id, &fingerprint).await? {
origin::check(&mut tx, &id, origin.as_ref()).await?;
return Ok(record);
}
if let Some(origin) = &origin {
origin::insert(&mut tx, origin).await?;
}
insert(&mut tx, &id, &fingerprint, &configuration, now).await?;
let record = read(&mut tx, &id)
.await?
.ok_or(StoreError::SessionNotFound)?;
tx.commit().await.map_err(StoreError::CommitUnknown)?;
Ok(record)
})
})
.await
}
/// Whether ordinary reads may see this Session. Archiving preserves visibility;
/// removing hides it before physical cleanup. This is not caller authorization
/// or admission for new work, and does not decode the Session's metadata.
pub async fn session_is_available(&self, id: &str) -> Result<bool, StoreError> {
self.validate_root()?;
validate_id(id)?;
let id = id.to_owned();
self.connection
.read(move |connection| {
Box::pin(async move {
Ok(sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM session_control WHERE id = ?1)
AND NOT EXISTS(SELECT 1 FROM session_retirements
WHERE session_id = ?1 AND remove_session != 0)",
)
.bind(id)
.fetch_one(connection)
.await?)
})
})
.await
}
pub async fn get_session<T: DeserializeOwned + Send + 'static>(
&self,
id: &str,
) -> Result<Option<SessionRecord<T>>, StoreError> {
self.validate_root()?;
validate_id(id)?;
let id = id.to_owned();
self.connection
.read(move |connection| {
Box::pin(async move {
let mut tx = connection.begin().await?;
if removal::read(&mut tx, &id)
.await?
.is_some_and(SessionRetirement::removes)
{
return Ok(None);
}
read(&mut tx, &id).await
})
})
.await
}
/// A continuation requires the revision returned with its first page.
pub async fn list_sessions<T: DeserializeOwned + Send + 'static>(
&self,
expected_revision: Option<&str>,
cursor: Option<&str>,
limit: usize,
) -> Result<SessionPage<T>, StoreError> {
self.session_page(
CatalogScope::Profile,
expected_revision,
cursor.into(),
limit,
CatalogFilter {
archived_only: false,
include_archived: true,
pending_only: false,
},
)
.await
}
/// Metadata within a Host-authorized scope; never plugin-defined SQL.
pub async fn scoped_sessions<T: DeserializeOwned + Send + 'static>(
&self,
scope: CatalogScope,
expected_revision: Option<&str>,
position: SessionPosition<'_>,
filter: CatalogFilter,
) -> Result<SessionPage<T>, StoreError> {
self.session_page(scope, expected_revision, position, MAX_SESSION_PAGE, filter)
.await
}
}
pub(crate) async fn insert(
tx: &mut SqliteConnection,
id: &str,
fingerprint: &str,
configuration: &str,
now: u64,
) -> Result<(), StoreError> {
validate_id(id)?;
validate_time(now)?;
let reserved: Option<String> =
sqlx::query_scalar("SELECT fingerprint FROM plugin_sessions WHERE session_id = ?")
.bind(id)
.fetch_optional(&mut *tx)
.await?;
if reserved.is_some_and(|reserved| reserved != fingerprint) {
return Err(StoreError::SessionConflict);
}
let inserted = sqlx::query(
"INSERT INTO session_control SELECT ?, ?, 1, ?, ?, 0, ?
WHERE NOT EXISTS (SELECT 1 FROM session_control WHERE id = ?)
AND NOT EXISTS (SELECT 1 FROM session_history_copies WHERE session_id = ?)
AND NOT EXISTS (SELECT 1 FROM session_retirements WHERE session_id = ?)
AND NOT EXISTS (SELECT 1 FROM event_log e JOIN imported_invocations i USING(invocation_id) WHERE e.event_session = ?)
AND NOT EXISTS (SELECT 1 FROM session_imports WHERE session_id = ? AND state != 'published')",
)
.bind(id)
.bind(fingerprint)
.bind(now as i64)
.bind(now as i64)
.bind(configuration)
.bind(id)
.bind(id)
.bind(id)
.bind(id)
.bind(id)
.execute(&mut *tx)
.await?;
if inserted.rows_affected() != 1 {
return Err(StoreError::SessionConflict);
}
advance_catalog(tx).await
}
pub(crate) async fn read<T: DeserializeOwned>(
connection: &mut SqliteConnection,
id: &str,
) -> Result<Option<SessionRecord<T>>, StoreError> {
let raw = sqlx::query(
"SELECT id, revision, created_at, updated_at, archived, configuration,
(SELECT lineage_json FROM session_history_copies WHERE session_id = id),
(SELECT state FROM session_history_copies WHERE session_id = id)
FROM session_control WHERE id = ?",
)
.bind(id)
.fetch_optional(&mut *connection)
.await?;
if let Some(row) = raw {
let configuration: String = row.try_get(5)?;
let configuration_digest =
maka_agent_facts::artifact::content_digest(configuration.as_bytes());
let configuration = serde_json::from_str(&configuration)?;
let lineage = row
.try_get::<Option<String>, _>(6)?
.map(|json| serde_json::from_str(&json))
.transpose()?;
let copy_state = row
.try_get::<Option<String>, _>(7)?
.map(|state| state.parse().map_err(invalid))
.transpose()?;
let execution = execution::read(connection, id).await?;
let last_message = execution::last_message(connection, id).await?;
let read_state = read_state::read(connection, id).await?;
let pending_since: Option<i64> = sqlx::query_scalar(
"SELECT MIN(created_at) FROM interaction_requests request
WHERE session_id = ? AND NOT EXISTS (
SELECT 1 FROM interaction_outcomes outcome
WHERE outcome.request_id = request.request_id)",
)
.bind(id)
.fetch_one(&mut *connection)
.await?;
let pending_interaction_since = pending_since
.map(|time| u64::try_from(time).map_err(|_| invalid("invalid interaction timestamp")))
.transpose()?;
Ok(Some(SessionRecord {
id: row.try_get(0)?,
revision: number(&row, 1)?,
created_at: number(&row, 2)?,
updated_at: number(&row, 3)?,
archived: row.try_get(4)?,
configuration,
configuration_digest,
execution,
last_message,
read_state,
pending_interaction_since,
lineage,
copy_state,
}))
} else {
Ok(None)
}
}
pub(crate) async fn advance_catalog(connection: &mut SqliteConnection) -> Result<(), StoreError> {
if sqlx::query(
"UPDATE session_catalog_revision SET revision = revision + 1 WHERE singleton = 1 AND revision < ?",
).bind(MAX_SAFE_INTEGER as i64).execute(connection).await?.rows_affected() != 1 {
return Err(invalid("session catalog revision exhausted"));
}
Ok(())
}
pub(crate) fn validate_id(id: &str) -> Result<(), StoreError> {
if id.is_empty()
|| !id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
{
return Err(invalid("invalid session ID"));
}
Ok(())
}
pub(crate) fn validate_time(now: u64) -> Result<(), StoreError> {
if now > MAX_SAFE_INTEGER {
return Err(invalid("timestamp exceeds safe integer range"));
}
Ok(())
}
fn invalid(message: &str) -> StoreError {
StoreError::InvalidTransition(message.into())
}
fn number(row: &sqlx::sqlite::SqliteRow, column: usize) -> Result<u64, StoreError> {
let value: i64 = row.try_get(column)?;
if value < 0 || value > MAX_SAFE_INTEGER as i64 {
return Err(invalid("stored session number exceeds safe integer range"));
}
Ok(value as u64)
}
async fn probe<T: DeserializeOwned>(
connection: &mut SqliteConnection,
id: &str,
fingerprint: &str,
) -> Result<Option<SessionRecord<T>>, StoreError> {
removal::require_accepting(connection, id).await?;
if fingerprint.is_empty() {
return Err(invalid("invalid session fingerprint"));
}
let previous: Option<String> =
sqlx::query_scalar("SELECT fingerprint FROM session_control WHERE id = ?")
.bind(id)
.fetch_optional(&mut *connection)
.await?;
if previous.is_some_and(|previous| previous != fingerprint) {
return Err(StoreError::SessionConflict);
}
read(connection, id).await
}