use std::sync::{Arc, Weak};
use super::{LiminalConnectionServices, ServerError};
use crate::auth_lys::drafts::admin::{AdminDrafts, AdminLimits, AdminRequest, AdminResponse};
use crate::auth_lys::drafts::{
AgentDraftStore, AgentProjection, DraftDecisionHook, DraftError, DraftPuller, PullReport,
PullSignal, TargetDraftStore,
};
use crate::health::endpoint::admin::DraftEndpoint;
struct Projection(Weak<LiminalConnectionServices>);
impl AgentProjection for Projection {
fn apply(&self, draft: &lys_pass::drafts::ApprovedDraft) -> Result<(), DraftError> {
self.0
.upgrade()
.ok_or(DraftError::Store)?
.execute_agent_draft(draft)
.map(|_| ())
.map_err(|_| DraftError::Store)
}
}
pub struct ApprovalRuntime {
route: tokio::sync::Mutex<AdminDrafts<TargetDraftStore>>,
services: Weak<LiminalConnectionServices>,
connector: zeroize::Zeroizing<String>,
handle: tokio::runtime::Handle,
limits: AdminLimits,
}
impl std::fmt::Debug for ApprovalRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApprovalRuntime").finish_non_exhaustive()
}
}
fn clock() -> Result<u64, DraftError> {
let elapsed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|_| DraftError::Contract)?;
u64::try_from(elapsed.as_millis()).map_err(|_| DraftError::Bound)
}
impl ApprovalRuntime {
async fn signal(&self, signal: PullSignal) -> Result<PullReport, DraftError> {
self.route
.lock()
.await
.signal(&self.connector, signal, clock()?)
.await
}
fn block<T: Send>(
&self,
future: impl Future<Output = Result<T, DraftError>> + Send,
) -> Result<T, ServerError> {
std::thread::scope(|scope| scope.spawn(|| self.handle.block_on(future)).join())
.map_err(|_| failure("draft pull worker failed"))?
.map_err(|error| failure(&error.to_string()))
}
}
impl DraftEndpoint for ApprovalRuntime {
fn limits(&self) -> AdminLimits {
self.limits
}
fn handle(&self, request: AdminRequest<'_>) -> Result<AdminResponse, ServerError> {
if request.path == crate::auth_lys::drafts::admin::CREATE_PATH {
return self.create(&request);
}
self.block(async {
self.route
.lock()
.await
.handle(request, &self.connector)
.await
})
}
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct CreateBody {
operation: String,
name: String,
spec: crate::agents::records::AgentSpec,
}
impl ApprovalRuntime {
fn create(&self, request: &AdminRequest<'_>) -> Result<AdminResponse, ServerError> {
let bad = |name: &str| AdminResponse {
status: 400,
body: serde_json::json!({ "refusal": name }),
};
if request.method != "POST" {
return Ok(bad("agent_create_method_refused"));
}
let Ok(body) = serde_json::from_slice::<CreateBody>(request.body) else {
return Ok(bad("agent_create_body_invalid"));
};
let Ok(target) = lys_pass::Target::new("liminal.agent", &body.name, "create_agent") else {
return Ok(bad("agent_create_name_invalid"));
};
let admitted = self.block(async {
let asked = self
.route
.lock()
.await
.outright(request.authorization, request.at_ms)?;
match asked {
Ok(ask) => ask.check(&target, &self.connector).await,
Err(refusal) => Ok(Err(refusal)),
}
})?;
let (pass, grant) = match admitted {
Ok(admitted) => admitted,
Err(refusal) => return Ok(refusal),
};
let act = crate::agents::records::AgentAct::prepare(
body.name.clone(),
crate::agents::records::AgentEvent::Create(body.spec),
pass.verified(),
request.at_ms,
);
let act = match act {
Ok(act) => act,
Err(error) => return Ok(named(409, &error.to_string())),
};
let words = serde_json::to_string(&act).map_err(|_| failure("agent create words"))?;
let draft = lys_pass::drafts::ApprovedDraft {
id: body.operation,
app: "liminal".to_owned(),
grant,
target,
request_digest: lys_pass::drafts::request_digest(&words),
approved_by: vec![pass.verified().claims().holder.id.clone()],
mode: lys_pass::Mode::Outright,
words,
};
let services = self
.services
.upgrade()
.ok_or_else(|| failure("server stopped"))?;
let owners = services
.approval_agents
.clone()
.ok_or_else(|| failure("agent approval owners missing"))?;
let approval = crate::auth_lys::drafts::ApprovalTransition {
draft: draft.id.clone(),
digest: draft.request_digest.clone(),
approved_by: draft.approved_by.clone(),
executed_at_ms: request.at_ms,
};
if let Err(error) = owners.journal().begin(&draft, &approval) {
return Ok(named(409, &error.to_string()));
}
let executed = owners
.agents()
.registry()
.execute_approved_at(&draft, request.at_ms)
.map_err(ServerError::from);
owners
.journal()
.finish()
.map_err(|error| failure(&error.to_string()))?;
let receipt = match executed {
Ok(receipt) => receipt,
Err(error) => return Ok(named(409, &error.to_string())),
};
owners
.journal()
.update(|snapshot| {
if let Some(outcome) = snapshot.outcomes.get_mut(&draft.id) {
outcome.acknowledged = true;
}
Ok(())
})
.map_err(|error| failure(&error.to_string()))?;
services.agent_created(&draft.target.id)?;
Ok(AdminResponse {
status: 200,
body: serde_json::to_value(receipt).map_err(|_| failure("agent create receipt"))?,
})
}
}
fn named(status: u16, reason: &str) -> AdminResponse {
AdminResponse {
status,
body: serde_json::json!({ "refusal": "agent_create_refused", "reason": reason }),
}
}
#[async_trait::async_trait]
impl DraftDecisionHook for ApprovalRuntime {
async fn draft_decided(&self) -> Result<PullReport, DraftError> {
self.signal(PullSignal::Decision).await
}
}
impl LiminalConnectionServices {
pub(in crate::server) fn draft_decision_hook(&self) -> Option<Arc<dyn DraftDecisionHook>> {
self.draft_hook.get().and_then(Weak::upgrade)
}
pub(in crate::server) fn install_approval_runtime(
self: &Arc<Self>,
lys: &crate::config::types::LysConfig,
lifecycle: Arc<crate::services::lifecycle::Lifecycle>,
http: Arc<dyn crate::services::lys_http::LysHttp>,
credential: String,
handle: tokio::runtime::Handle,
health: &crate::health::HealthServerHandle,
) -> Result<(), ServerError> {
let owners = self
.approval_agents
.as_ref()
.ok_or_else(|| failure("construction requires configured agent approval owners"))?;
let delivery = self
.agent_delivery
.as_ref()
.ok_or_else(|| failure("agent outcome owner missing"))?;
let limits = lys
.drafts
.ok_or_else(|| failure("auth.lys.drafts is required"))?;
let holder = Arc::new(crate::auth_lys::prepared::SiteHolder {
lifecycle: lifecycle.clone(),
owners: owners.clone(),
config: lys.clone(),
handle: handle.clone(),
});
self.host_config
.set((lys.clone(), handle.clone()))
.map_err(|_| failure("host configuration already installed"))?;
if lys.construction.is_some() {
self.install_construction(lifecycle.clone(), lys, http, holder)
.map_err(|error| failure(&error.to_string()))?;
} else {
self.site_lifecycle
.set(lifecycle.clone())
.map_err(|_| failure("lifecycle already installed"))?;
}
for record in owners.agents().registry().records()? {
if record.active() {
self.agent_created(&record.name)?;
}
}
let agents = AgentDraftStore::new(owners, delivery.clone())
.with_projection(Arc::new(Projection(Arc::downgrade(self))));
let sites = crate::services::lifecycle::site::executor::SiteDraftStore::new(
lifecycle,
owners.journal().clone(),
delivery.clone(),
0,
);
let store = TargetDraftStore { agents, sites };
let puller = DraftPuller::new(
lys.api_url.as_deref().unwrap_or(&lys.issuer),
store,
limits.pull,
)
.map_err(|error| failure(&error.to_string()))?;
let keys = std::fs::read_to_string(&lys.keys_path)
.map_err(|_| failure("draft keyset unreadable"))?;
let adapter = Arc::new(
crate::auth_lys::lys_pass_adapter::LysPassAdapter::from_keys_json(
&keys,
lys.issuer.clone(),
)
.map_err(|error| failure(error.name()))?,
);
let route = AdminDrafts::new(puller, adapter, lys.network_id.clone(), limits.http)
.map_err(|error| failure(&error.to_string()))?
.asking_at(lys.api_url.clone());
let runtime = Arc::new(ApprovalRuntime {
route: tokio::sync::Mutex::new(route),
services: Arc::downgrade(self),
connector: zeroize::Zeroizing::new(credential),
handle,
limits: limits.http,
});
runtime.block(runtime.signal(PullSignal::Startup))?;
let hook: Arc<dyn DraftDecisionHook> = runtime.clone();
self.draft_hook
.set(Arc::downgrade(&hook))
.map_err(|_| failure("draft decision hook already installed"))?;
health.install_drafts(runtime)?;
Ok(())
}
}
fn failure(message: &str) -> ServerError {
ServerError::ConfigValidation {
message: message.into(),
}
}