blob: 4297e9bb3836c902fdac7dcc75ffe43b3f47cce5 [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.
*/
//! One-shot Host resource facts outside an Agent invocation. Only the Host
//! adapter writes these records; plugin business state uses separate storage.
use crate::{EventLog, StoreError};
use maka_capabilities::call::Identity;
use maka_capabilities::storage::Namespace;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::{Connection, Row};
use uuid::Uuid;
/// Durable observation of the original resource owner; never a live grant.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Owner {
pub package_id: String,
pub entry_id: String,
pub scope: maka_plugin_core::scope::Scope,
pub activation: String,
pub generation: u64,
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "kind", content = "input", rename_all = "snake_case")]
pub enum Operation {
Http {
method: maka_capabilities::http::Method,
url: String,
body_digest: String,
body_bytes: usize,
},
Notification {
input: maka_capabilities::client_capability::Notification,
provider: maka_agent_facts::capability::Identity,
registration_id: String,
},
File(maka_capabilities::filesystem::Operation),
Model {
input: maka_agent_ports::llm::Generate,
model: maka_agent_facts::execution::ModelBinding,
thinking_level: Option<maka_agent_facts::execution::ThinkingLevel>,
},
Client {
input: maka_capabilities::client_capability::Call,
provider: maka_agent_facts::capability::Identity,
registration_id: String,
offer_id: String,
tool_call_id: String,
},
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Request {
pub source: Identity,
pub owner: Owner,
pub boundary: maka_capabilities::authorization::Boundary,
pub operation: Operation,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "snake_case",
rename_all_fields = "camelCase"
)]
pub enum Outcome {
Completed { value: Value },
Image { mime_type: String, digest: String },
Failed { message: String },
Unknown { message: String },
}
pub struct Record {
pub request: Request,
pub outcome: Option<Outcome>,
pub payload: Option<Vec<u8>>,
}
impl EventLog {
/// Caller may start the effect only after this durable admission succeeds.
pub async fn begin_host_effect(&self, request: Request) -> Result<Uuid, StoreError> {
self.validate_root()?;
if matches!(request.source, Identity::Agent { .. }) {
return Err(invalid("Agent effects belong to their invocation journal"));
}
let namespace = Namespace::new(
request.owner.package_id.clone(),
request.owner.scope.clone(),
)
.map_err(|error| invalid(error.to_string()))?;
let raw = serde_json::to_string(&request)?;
let session = match &request.boundary {
maka_capabilities::authorization::Boundary::Session { boundary, .. } => {
Some(boundary.session_id.clone())
}
_ => None,
};
let id = Uuid::new_v4();
self.connection.run(move |connection| {
Box::pin(async move {
let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
if let Some(session) = session { crate::sessions::retain(&mut tx, &session).await?; }
sqlx::query("INSERT INTO host_effects (id, package_id, scope_id, request) VALUES (?, ?, ?, ?)")
.bind(id.to_string()).bind(namespace.package()).bind(String::from(namespace.scope().clone())).bind(raw)
.execute(&mut *tx).await?;
tx.commit().await.map_err(StoreError::CommitUnknown)?;
Ok(id)
})
}).await
}
/// Settlement cannot overwrite a known outcome, including recovery's unknown.
pub async fn settle_host_effect(
&self,
id: Uuid,
outcome: Outcome,
payload: Option<Vec<u8>>,
) -> Result<(), StoreError> {
self.validate_root()?;
match (&outcome, &payload) {
(Outcome::Image { mime_type, digest }, Some(bytes))
if matches!(
mime_type.as_str(),
"image/png" | "image/jpeg" | "image/gif" | "image/webp"
) && &maka_agent_facts::artifact::content_digest(bytes) == digest => {}
(Outcome::Image { .. }, _) | (_, Some(_)) => {
return Err(invalid("invalid effect image payload"));
}
_ => {}
}
let raw = serde_json::to_string(&outcome)?;
self.connection.run(move |connection| {
Box::pin(async move {
let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
let result = sqlx::query("UPDATE host_effects SET outcome = ?, payload = ? WHERE id = ? AND outcome IS NULL")
.bind(raw).bind(payload).bind(id.to_string()).execute(&mut *tx).await?;
if result.rows_affected() != 1 {
return Err(StoreError::EventConflict);
}
tx.commit().await.map_err(StoreError::CommitUnknown)?;
Ok(())
})
}).await
}
pub async fn host_effect(
&self,
namespace: Namespace,
id: Uuid,
) -> Result<Option<Record>, StoreError> {
self.validate_root()?;
self.connection.run(move |connection| {
Box::pin(async move {
let row = sqlx::query("SELECT request, outcome, payload FROM host_effects WHERE id = ? AND package_id = ? AND scope_id = ?")
.bind(id.to_string()).bind(namespace.package()).bind(String::from(namespace.scope().clone()))
.fetch_optional(connection).await?;
row.map(|row| {
Ok(Record {
request: serde_json::from_str(row.try_get("request")?)?,
outcome: row.try_get::<Option<&str>, _>("outcome")?.map(serde_json::from_str).transpose()?,
payload: row.try_get("payload")?,
})
}).transpose()
})
}).await
}
/// Called only at exclusive Host recovery, before accepting new resource work.
pub async fn recover_host_effects(&self) -> Result<(), StoreError> {
self.validate_root()?;
let outcome = serde_json::to_string(&Outcome::Unknown {
message:
"Host stopped before recording the effect outcome; do not replay automatically"
.into(),
})?;
self.connection
.run(move |connection| {
Box::pin(async move {
let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
sqlx::query("UPDATE host_effects SET outcome = ? WHERE outcome IS NULL")
.bind(outcome)
.execute(&mut *tx)
.await?;
tx.commit().await.map_err(StoreError::CommitUnknown)?;
Ok(())
})
})
.await
}
}
fn invalid(message: impl Into<String>) -> StoreError {
StoreError::InvalidTransition(message.into())
}