blob: 97686c537dc84470dc5972081f2956d3951e041d [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 UI requests. The foreground owns deployment edits and their receipts.
pub mod contract;
use contract::*;
use futures_util::future::BoxFuture;
use maka_capabilities::{
call::Scope,
storage::{Data, Mutation, Namespace},
};
use maka_event_log::EventLog;
use maka_host_runtime::application::{
Context, Handler, NativeActivation, NativeContext, NativeOwner, NativeRegistry,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{collections::BTreeMap, sync::Arc};
use tokio::sync::{Mutex, Notify};
#[derive(Clone)]
struct Requests {
store: Arc<EventLog>,
prefix: String,
writes: Arc<Mutex<()>>,
changed: Arc<Notify>,
}
#[derive(Serialize, Deserialize)]
struct Record {
session: String,
request: Request,
state: State,
}
fn namespace() -> Namespace {
Namespace::new(IMPLEMENTATION, maka_plugin_core::scope::Scope::Profile).unwrap()
}
fn encode<T: Serialize>(value: T) -> Result<Value, String> {
serde_json::to_value(value).map_err(|e| e.to_string())
}
fn decode<T: serde::de::DeserializeOwned>(value: Value) -> Result<T, String> {
serde_json::from_value(value).map_err(|e| e.to_string())
}
pub fn provide(registry: &mut NativeRegistry) -> Result<(), String> {
registry.insert(
IMPLEMENTATION.into(),
Arc::new(|context, _| Box::pin(activate(context))),
)
}
async fn activate(context: NativeContext) -> NativeActivation {
let Some(persistence) = context.persistence() else {
return NativeActivation {
exports: Err("UI authoring requires persistence".into()),
owner: Arc::new(()),
};
};
let owner = Arc::new(Requests {
store: persistence.store().clone(),
prefix: maka_host_runtime::storage::instance_prefix(
&persistence.source().state_domain,
persistence.source().deployment,
context.activation().instance(),
),
writes: Arc::new(Mutex::new(())),
changed: Arc::new(Notify::new()),
});
NativeActivation {
exports: Ok(BTreeMap::from([(
EXPORT.into(),
maka_plugin_core::runtime::Published {
contract: contract(),
value: owner.clone() as Arc<dyn Handler>,
},
)])),
owner,
}
}
impl Requests {
fn key(&self, id: &str) -> Result<String, String> {
if id.len() != 64 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err("invalid UI request identity".into());
}
Ok(format!("{}request/{id}", self.prefix))
}
async fn record(&self, id: &str) -> Result<(u64, Record), String> {
let record = self
.store
.plugin_data(&namespace(), &self.key(id)?)
.await
.map_err(|e| e.to_string())?
.ok_or("UI request unavailable")?;
let value = record
.data
.value()
.cloned()
.ok_or("UI request unavailable")?;
Ok((record.revision, decode(value)?))
}
async fn queue(&self) -> Result<(Option<u64>, Vec<String>), String> {
let record = self
.store
.plugin_data(&namespace(), &format!("{}queue", self.prefix))
.await
.map_err(|e| e.to_string())?;
match record {
Some(record) => Ok((
Some(record.revision),
decode(record.data.value().cloned().ok_or("invalid UI queue")?)?,
)),
None => Ok((None, vec![])),
}
}
async fn write(&self, changes: Vec<Mutation>) -> Result<(), String> {
self.store
.plugin_data_batch(&namespace(), changes)
.await
.map_err(|e| e.to_string())?;
self.changed.notify_waiters();
Ok(())
}
async fn submit(&self, session: String, id: String, command: Command) -> Result<Value, String> {
let _write = self.writes.lock().await;
if self
.store
.plugin_data(&namespace(), &self.key(&id)?)
.await
.map_err(|e| e.to_string())?
.is_some()
{
return encode(RequestId { id });
}
let (revision, mut queue) = self.queue().await?;
if queue.len() >= 16 {
return Err("UI request queue is full; wait for the foreground".into());
}
queue.push(id.clone());
let record = Record {
session,
request: Request {
id: id.clone(),
command,
},
state: State::Queued,
};
self.write(vec![
Mutation {
key: self.key(&id)?,
expected_revision: None,
data: Data::Present(encode(record)?),
},
Mutation {
key: format!("{}queue", self.prefix),
expected_revision: revision,
data: Data::Present(encode(queue)?),
},
])
.await?;
encode(RequestId { id })
}
async fn claim(&self) -> Result<Value, String> {
let _write = self.writes.lock().await;
let (revision, mut queue) = self.queue().await?;
if queue.is_empty() {
return encode(Claim { request: None });
}
let id = queue.remove(0);
let (record_revision, mut record) = self.record(&id).await?;
if !matches!(record.state, State::Queued) {
return Err("UI queue contains an already claimed request".into());
}
record.state = State::Claimed;
let request = record.request.clone();
self.write(vec![
Mutation {
key: self.key(&id)?,
expected_revision: Some(record_revision),
data: Data::Present(encode(record)?),
},
Mutation {
key: format!("{}queue", self.prefix),
expected_revision: revision,
data: Data::Present(encode(queue)?),
},
])
.await?;
encode(Claim {
request: Some(request),
})
}
async fn complete(&self, input: Completion) -> Result<Value, String> {
let _write = self.writes.lock().await;
let (revision, mut record) = self.record(&input.id).await?;
match &record.state {
State::Queued => return Err("UI request has not been claimed".into()),
State::Completed { outcome } => return encode(outcome == &input.outcome),
State::Claimed => {}
}
record.state = State::Completed {
outcome: input.outcome,
};
self.write(vec![Mutation {
key: self.key(&input.id)?,
expected_revision: Some(revision),
data: Data::Present(encode(record)?),
}])
.await?;
encode(true)
}
async fn result(&self, session: Option<&str>, id: &str) -> Result<State, String> {
let (_, record) = self.record(id).await?;
if session != Some(record.session.as_str()) {
return Err("UI request belongs to another Session".into());
}
Ok(record.state)
}
async fn wait_result(
&self,
scope: &Scope,
session: Option<&str>,
id: &str,
) -> Result<Value, String> {
let deadline = tokio::time::sleep(std::time::Duration::from_secs(5));
tokio::pin!(deadline);
loop {
let changed = self.changed.notified();
tokio::pin!(changed);
changed.as_mut().enable();
let state = self.result(session, id).await?;
if matches!(state, State::Completed { .. }) {
return encode(state);
}
tokio::select! {
_=changed=>{},
_=&mut deadline=>return encode(self.result(session,id).await?),
_=scope.cancellation.cancelled()=>return Err("UI result observation cancelled".into()),
}
}
}
// SQL custody outlives a dropped observer. Its original call retains the ticket.
async fn run(
self,
scope: Scope,
method: String,
input: Value,
id: Option<String>,
session: Option<String>,
) -> Result<Value, String> {
let mut ticket = scope.resources.reserve().map_err(|e| e.to_string())?;
tokio::spawn(async move {
ticket.start();
let result = async {
if scope.cancellation.is_cancelled() {
return Err("UI authoring call cancelled".into());
}
match method.as_str() {
"submit" => {
self.submit(
session.ok_or("UI requests require an Agent invocation")?,
id.ok_or("UI request requires a mutation")?,
decode(input)?,
)
.await
}
"claim" => {
decode::<Empty>(input)?;
self.claim().await
}
"complete" => self.complete(decode(input)?).await,
"result" => {
let request: RequestId = decode(input)?;
self.wait_result(&scope, session.as_deref(), &request.id)
.await
}
_ => Err("UI authoring method unavailable".into()),
}
}
.await;
ticket.complete(Ok(()));
result
})
.await
.map_err(|_| "UI authoring work did not settle".to_owned())?
}
}
impl Handler for Requests {
fn call(
&self,
method: String,
input: Value,
context: Context,
) -> BoxFuture<'static, Result<Value, String>> {
let own = self.clone();
Box::pin(async move {
if method == "watch" {
decode::<Empty>(input)?;
let mut last = None;
loop {
let changed = own.changed.notified();
tokio::pin!(changed);
changed.as_mut().enable();
let (revision, queue) = own.queue().await?;
if last != Some(revision) {
context.emit(json!({"pending":queue.len()})).await?;
last = Some(revision)
}
tokio::select! { _=changed=>{},_=context.scope().cancellation.cancelled()=>return Ok(Value::Null) }
}
}
let id = context.operation().map(|operation| {
hex::encode(Sha256::digest(serde_json::to_vec(operation).unwrap()))
});
let session = context
.scope()
.identity
.agent()
.map(|invocation| invocation.session_id.clone());
own.run(context.scope().clone(), method, input, id, session)
.await
})
}
}
impl NativeOwner for Requests {
fn execution_basis(&self) -> Option<Value> {
let root = self.store.owned_root().ok()?;
let namespace = namespace();
serde_json::to_value((
root.root_id(),
namespace.package(),
namespace.scope(),
&self.prefix,
))
.ok()
}
fn prepare_retirement(
&self,
) -> Option<Box<dyn maka_host_runtime::application::NativeRetirement>> {
Some(Box::new(()))
}
fn close(&self) -> BoxFuture<'static, Result<(), String>> {
Box::pin(async { Ok(()) })
}
}
#[cfg(test)]
mod tests {
use super::*;
use maka_event_log::root::{RootNamespaces, RootOwner};
#[test]
fn submit_tool_schema_declares_object_and_keeps_command_variants() {
let maka_plugin_core::contract::Interface::Service { methods } = contract().interface
else {
panic!("authoring service");
};
let schema = &methods["submit"].input;
assert_eq!(schema["type"], "object");
assert_eq!(schema["oneOf"].as_array().unwrap().len(), 10);
let command = json!({"method":"instances","input":{"kind":"start"}});
assert!(serde_json::from_value::<Command>(command).is_ok());
assert!(serde_json::from_value::<Command>(json!({"method":"instances"})).is_err());
assert!(serde_json::from_value::<Command>(json!({"method":"unknown","input":{}})).is_err());
}
fn requests(store: Arc<EventLog>) -> Requests {
Requests {
store,
prefix: "ui-test/".into(),
writes: Arc::new(Mutex::new(())),
changed: Arc::new(Notify::new()),
}
}
#[tokio::test]
async fn claimed_requests_survive_reactivation_without_replay_and_keep_session_authority() {
let dir = tempfile::tempdir().unwrap();
let owner = Arc::new(
RootOwner::create(
&dir.path().join("root"),
&RootNamespaces {
ownership: dir.path().join("owners"),
control: dir.path().join("control"),
},
)
.unwrap(),
);
let store = Arc::new(EventLog::for_root(owner).await.unwrap());
let first = requests(store.clone());
let basis = first.execution_basis().unwrap();
let mut other = requests(store.clone());
other.prefix = "other/".into();
assert_ne!(other.execution_basis().unwrap(), basis);
let id = "a".repeat(64);
let command: Command =
serde_json::from_value(json!({"method":"instances","input":{"kind":"start"}})).unwrap();
first
.submit("session-a".into(), id.clone(), command.clone())
.await
.unwrap();
first
.submit("session-a".into(), id.clone(), command)
.await
.unwrap();
assert_eq!(first.queue().await.unwrap().1.len(), 1);
assert!(
first
.complete(Completion {
id: id.clone(),
outcome: json!({})
})
.await
.is_err()
);
assert!(first.result(Some("session-b"), &id).await.is_err());
let (one, two) = tokio::join!(first.claim(), first.claim());
let claims = [one.unwrap(), two.unwrap()];
assert_eq!(claims.iter().filter(|v| !v["request"].is_null()).count(), 1);
drop(first);
let resumed = requests(store);
assert_eq!(resumed.execution_basis().unwrap(), basis);
assert!(resumed.claim().await.unwrap()["request"].is_null());
assert!(matches!(
resumed.result(Some("session-a"), &id).await.unwrap(),
State::Claimed
));
let original = json!({"status":"operation","reply":{"receipt":{"kind":"unknown"}}});
assert_eq!(
resumed
.complete(Completion {
id: id.clone(),
outcome: original.clone()
})
.await
.unwrap(),
true
);
assert_eq!(
resumed
.complete(Completion {
id: id.clone(),
outcome: original.clone()
})
.await
.unwrap(),
true
);
assert_eq!(
resumed
.complete(Completion {
id: id.clone(),
outcome: json!({"status":"succeeded"})
})
.await
.unwrap(),
false
);
match resumed.result(Some("session-a"), &id).await.unwrap() {
State::Completed { outcome } => assert_eq!(outcome, original),
_ => panic!("original outcome was lost"),
}
}
}