blob: a1ed522d0086372434b28e0cc0c472628e9b2d62 [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.
*/
//! Ordinary skill library service, reusing the original capability-scoped discovery/parser.
//! Files are data; neither references nor declared tools authorize execution.
use maka_skills_api::contract;
mod management;
mod model;
pub mod outcomes;
mod preferences;
mod preparation;
use contract::*;
use futures_util::future::BoxFuture;
use maka_capabilities::call::Scope;
use maka_capabilities::filesystem::ReadRoot;
use maka_host_runtime::application::{
Context, Handler, NativeActivation, NativeContext, NativeOwner, NativeRegistry,
};
use maka_sandbox::filesystem::{Access, Policy, Rule};
use serde::Serialize;
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet},
path::PathBuf,
sync::Arc,
};
mod imports;
mod sources;
use sources::CapturedSource;
use maka_skills_api::{EXPORT, IMPLEMENTATION};
// Even maximally escaped control bytes leave room for metadata in the JSON budget.
const TEXT_BYTES: usize = 4096;
pub fn provide(registry: &mut NativeRegistry) -> Result<(), String> {
registry.insert(
maka_skills_api::IMPLEMENTATION.into(),
Arc::new(|context, configuration| Box::pin(activate(context, configuration))),
)?;
outcomes::provide(registry)
}
async fn activate(context: NativeContext, configuration: Value) -> NativeActivation {
let opened = async {
let persistence = context.persistence().ok_or("skills_require_persistence")?;
let root = persistence
.store()
.owned_root()
.map_err(|_| "skills_root_unavailable")?;
let managed = root.canonical_path().to_owned();
let configuration: Configuration =
serde_json::from_value(configuration).map_err(|_| "skills_configuration_invalid")?;
let manage = configuration.manage;
let namespace = maka_host_runtime::storage::instance_prefix(
&persistence.source().state_domain,
persistence.source().deployment,
context.activation().instance(),
);
let preferences = preferences::Store::new(persistence.store().clone(), namespace.clone());
let mut library =
tokio::task::spawn_blocking(move || Library::open(configuration, managed, preferences))
.await
.map_err(|_| "skills_open_failed")??;
if manage {
library.managed = Some(management::Managed::open(root, namespace).await?);
}
Ok::<_, String>(library)
}
.await;
match opened {
Ok(owner) => {
let owner = Arc::new(owner);
NativeActivation {
exports: Ok(BTreeMap::from([(
EXPORT.into(),
maka_plugin_core::runtime::Published {
contract: contract(),
value: owner.clone() as Arc<dyn Handler>,
},
)])),
owner,
}
}
Err(error) => NativeActivation {
exports: Err(error),
owner: Arc::new(()),
},
}
}
#[derive(Clone)]
struct Library {
bundled: bool,
sources: Vec<CapturedSource>,
projects: Vec<Arc<maka_fs_tools::workspace::directory::PublishedDirectory>>,
private: PathBuf,
workspace: Option<maka_agent_api::contract::Workspace>,
policy: Arc<maka_sandbox::filesystem::Compiled>,
preferences: preferences::Store,
preferences_writable: bool,
managed: Option<Arc<management::Managed>>,
}
impl Library {
fn open(
configuration: Configuration,
managed: PathBuf,
preferences: preferences::Store,
) -> Result<Self, String> {
if configuration.bundled {
maka_skills::prepare_bundled().map_err(|_| "skills_bundled_unavailable")?;
}
let mut ids = BTreeSet::new();
let policy = Arc::new(
Policy {
default: Access::Read,
rules: vec![Rule::subtree(&managed, Access::Deny)],
deny_globs: vec![],
}
.compile()
.map_err(|_| "skills_configuration_invalid")?,
);
let mut sources = Vec::new();
for source in configuration.sources {
if source.id.is_empty()
|| !source
.id
.bytes()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_' || c == b'-')
|| !source.id.as_bytes()[0].is_ascii_alphanumeric()
|| !ids.insert(source.id.clone())
|| source.label.trim().is_empty()
|| !std::path::Path::new(&source.path).is_absolute()
{
return Err("skills_configuration_invalid".into());
}
let root = ReadRoot::capture(&source.path)
.map_err(|_| "skills_source_unavailable")?
.restrict(policy.clone())
.map_err(|_| "skills_source_unavailable")?;
sources.push(CapturedSource::custom(source, root));
}
if let Some(home) = configuration.home {
sources.extend(sources::home(&home, policy.clone())?);
}
let projects = sources::projects(configuration.project_roots, &managed)?;
Ok(Self {
projects,
private: managed,
workspace: None,
policy,
bundled: configuration.bundled,
sources,
preferences,
preferences_writable: configuration.preferences,
managed: None,
})
}
async fn discover(&self, scope: &Scope) -> Result<maka_skills::DiscoverySnapshot, String> {
let mut sources: Vec<_> = self
.sources
.iter()
.map(|source| source.bind(scope))
.collect();
if let Some(managed) = &self.managed {
let access = managed.read.bind_scope(scope);
sources.insert(
self.sources.iter().take_while(|s| s.project()).count(),
maka_skills::Source {
root: access.location(),
access,
directory: PathBuf::from("skills"),
scope: maka_skills::SkillScope::Workspace,
source: maka_skills::SkillSource::Maka,
reference_prefix: "installed".into(),
},
);
}
let snapshot = if self.bundled {
maka_skills::scan_with_bundled(&sources, &scope.cancellation).await
} else {
maka_skills::scan(&sources, &scope.cancellation).await
};
snapshot.map_err(|_| "skills_discovery_unavailable".into())
}
async fn snapshot(&self, scope: &Scope) -> Result<Snapshot, String> {
let mut discovery = self.discover(scope).await?;
let mut imported_diagnostics = Vec::new();
if let Some(managed) = &self.managed {
let imported = managed.imported_snapshot(scope).await?;
discovery.inventory.extend(imported.inventory);
discovery.rejected.extend(imported.rejected);
imported_diagnostics = imported
.diagnostics
.iter()
.map(|diagnostic| format!("Imported: {:?}", diagnostic.reason))
.collect();
}
let preferences = self.preferences.read(scope).await?;
let label = |reference: &str| {
if reference.starts_with("imported:") {
return "Imported";
}
if reference.starts_with("bundled:") {
return "Built-in";
}
if reference.starts_with("installed:") {
return "Installed";
}
self.sources
.iter()
.find(|source| source.contains(reference))
.map(|source| source.metadata.label.as_str())
.unwrap_or("")
};
let mut entries = Vec::new();
for skill in discovery.inventory {
let entry = entry(
&skill.location.reference,
label(&skill.location.reference),
&skill.document.manifest.name,
&skill.document.manifest.description,
true,
skill.shadowed_by,
&skill.document.issues,
);
entries.push((entry, skill.content_sha256, skill.document.body));
}
for skill in discovery.rejected {
let entry = entry(
&skill.location.reference,
label(&skill.location.reference),
skill
.document
.manifest
.name
.as_deref()
.unwrap_or(&skill.location.id),
skill.document.manifest.description.as_deref().unwrap_or(""),
false,
None,
&skill.document.issues,
);
entries.push((entry, skill.content_sha256, skill.document.body));
}
for (entry, _, _) in &mut entries {
if entry.imported {
entry.preference = maka_skills::Preference {
enabled: false,
pinned: false,
};
continue;
}
entry.preference = preferences
.entries
.get(&entry.reference)
.copied()
.unwrap_or_default();
}
entries.sort_by(|a, b| a.0.reference.cmp(&b.0.reference));
let diagnostics: Vec<_> = discovery
.diagnostics
.iter()
.map(|diagnostic| {
let project_count = self
.sources
.iter()
.take_while(|source| source.project())
.count();
let label = if self.managed.is_some() && diagnostic.precedence == project_count {
"Installed"
} else {
&self.sources[diagnostic.precedence
- usize::from(
self.managed.is_some() && diagnostic.precedence > project_count,
)]
.metadata
.label
};
format!("{label}: {:?}", diagnostic.reason)
})
.chain(imported_diagnostics)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let revision = maka_agent_facts::artifact::content_digest(
&serde_json::to_vec(&(
&self.workspace,
self.managed.is_some(),
self.preferences_writable,
preferences.revision,
self.sources
.iter()
.map(|source| (&source.metadata, &source.directory, &source.prefix))
.collect::<Vec<_>>(),
entries
.iter()
.map(|(entry, digest, _)| (entry, digest))
.collect::<Vec<_>>(),
&diagnostics,
))
.map_err(|_| "skills_encoding_failed")?,
);
Ok(Snapshot {
writable: self.managed.is_some(),
preferences_writable: self.preferences_writable,
preference_revision: preferences.revision,
entries,
diagnostics,
revision,
})
}
}
struct Snapshot {
writable: bool,
preferences_writable: bool,
preference_revision: u64,
entries: Vec<(Entry, String, String)>,
diagnostics: Vec<String>,
revision: String,
}
fn bound(value: &str, limit: usize) -> String {
let mut end = value.len().min(limit);
while !value.is_char_boundary(end) {
end -= 1;
}
value[..end].to_owned()
}
fn entry(
reference: &str,
source: &str,
name: &str,
description: &str,
valid: bool,
shadowed_by: Option<String>,
issues: &[maka_skills::Issue],
) -> Entry {
Entry {
bundled: reference.starts_with("bundled:"),
installed: reference.starts_with("installed:"),
imported: reference.starts_with("imported:"),
preference: Default::default(),
reference: reference.into(),
source: source.into(),
name: name.into(),
description: description.into(),
valid,
shadowed_by,
metadata_truncated: false,
issues: issues.iter().map(|issue| issue.message.clone()).collect(),
}
}
impl Snapshot {
fn list(self, input: List) -> Result<Page, String> {
let query = input.query.to_lowercase();
let filtered: Vec<_> = self
.entries
.into_iter()
.filter(|(entry, _, _)| {
query.split_whitespace().all(|term| {
format!(
"{} {} {} {}",
entry.reference, entry.source, entry.name, entry.description
)
.to_lowercase()
.contains(term)
})
})
.map(|(entry, _, _)| entry)
.collect();
let offset = match input.cursor {
Some(cursor) if cursor.revision != self.revision => return Ok(Page::Changed),
Some(cursor) if cursor.query != input.query => {
return Err("skills_cursor_invalid".into());
}
Some(cursor) => cursor.offset,
None => 0,
};
if offset > filtered.len() {
return Err("skills_cursor_invalid".into());
}
let mut entries = Vec::new();
let after = |n| {
(offset + n < filtered.len()).then(|| Cursor {
revision: self.revision.clone(),
query: input.query.clone(),
offset: offset + n,
})
};
for entry in filtered.iter().skip(offset).take(32) {
entries.push(entry.clone());
let page = Page::Page {
writable: self.writable,
preferences_writable: self.preferences_writable,
preference_revision: self.preference_revision,
revision: self.revision.clone(),
entries: entries.clone(),
after: after(entries.len()),
diagnostics: self.diagnostics.clone(),
};
if entries.len() > 1 && !maka_workspace_support::fits(&page) {
entries.pop();
break;
}
}
let after = after(entries.len());
Ok(Page::Page {
writable: self.writable,
preferences_writable: self.preferences_writable,
preference_revision: self.preference_revision,
revision: self.revision,
after,
entries,
diagnostics: self.diagnostics,
})
}
fn read(self, input: Read) -> Result<Document, String> {
if input.revision != self.revision {
return Ok(Document::Changed);
}
let Some((entry, _, text)) = self
.entries
.into_iter()
.find(|(entry, _, _)| entry.reference == input.reference)
else {
return Ok(Document::Unavailable);
};
let start = input.offset;
if start > text.len() || !text.is_char_boundary(start) {
return Err("skills_position_invalid".into());
}
let mut end = (start + TEXT_BYTES).min(text.len());
while !text.is_char_boundary(end) {
end -= 1;
}
let mut before = start.saturating_sub(TEXT_BYTES);
while !text.is_char_boundary(before) {
before += 1;
}
Ok(Document::Page {
writable: self.writable && !entry.bundled,
preferences_writable: self.preferences_writable,
preference_revision: self.preference_revision,
entry: Box::new(entry),
revision: self.revision,
text: text[start..end].into(),
offset: start,
before: (start > 0).then_some(before),
after: (end < text.len()).then_some(end),
total_bytes: text.len(),
})
}
}
fn encode(value: impl Serialize) -> Result<Value, String> {
let value = serde_json::to_value(value).map_err(|_| "skills_encoding_failed")?;
Ok(value)
}
impl Handler for Library {
fn call(
&self,
method: String,
input: Value,
context: Context,
) -> BoxFuture<'static, Result<Value, String>> {
let library = self.clone();
Box::pin(async move {
match method.as_str() {
"prepare" | "search" | "load" => {
library.model(&method, input, context.scope()).await
}
"list" => {
let input =
serde_json::from_value(input).map_err(|_| "skills_query_invalid")?;
let input: List = input;
let library = library
.for_workspace(input.workspace.clone(), context.scope())
.await?;
encode(library.snapshot(context.scope()).await?.list(input)?)
}
"read" => {
let input =
serde_json::from_value(input).map_err(|_| "skills_query_invalid")?;
let input: Read = input;
let library = library
.for_workspace(input.workspace.clone(), context.scope())
.await?;
encode(library.snapshot(context.scope()).await?.read(input)?)
}
"preview" => {
let input =
serde_json::from_value(input).map_err(|_| "skills_change_invalid")?;
let input: Change = input;
let library = library
.for_workspace(input.workspace.clone(), context.scope())
.await?;
encode(library.preview(input, context.scope()).await?)
}
"setPreference" => {
let input =
serde_json::from_value(input).map_err(|_| "skills_preference_invalid")?;
context.operation().ok_or("skills_mutation_required")?;
let input: SetPreference = input;
if input.reference.starts_with("imported:") {
return encode(PreferenceResult::Rejected {
reason: "read_only_source".into(),
});
}
if !library.preferences_writable {
return encode(PreferenceResult::Rejected {
reason: "read_only_preferences".into(),
});
}
encode(library.preferences.set(input, context.scope()).await?)
}
"importSource" => {
let input =
serde_json::from_value(input).map_err(|_| "skills_change_invalid")?;
let operation = context.operation().ok_or("skills_mutation_required")?;
encode(
library
.import_source(
input,
operation,
context.scope(),
context.attachments().clone(),
)
.await?,
)
}
"apply" | "deleteSource" => {
let input =
serde_json::from_value(input).map_err(|_| "skills_change_invalid")?;
let operation = context.operation().ok_or("skills_mutation_required")?;
let input: Review = input;
let library = library
.for_workspace(input.change.workspace.clone(), context.scope())
.await?;
encode(if method == "deleteSource" {
library
.delete_source(
input,
&management::operation(operation)?,
context.scope(),
)
.await?
} else {
library.apply(input, operation, context.scope()).await?
})
}
_ => Err("skills_method_unavailable".into()),
}
})
}
}
impl NativeOwner for Library {
fn execution_basis(&self) -> Option<Value> {
let sources = self
.sources
.iter()
.map(|source| source.root.execution_basis())
.collect::<std::io::Result<Vec<_>>>()
.ok()?;
let projects = self
.projects
.iter()
.map(|project| {
project
.identity()
.map(|identity| (project.path(), identity))
})
.collect::<std::io::Result<Vec<_>>>()
.ok()?;
let managed = match &self.managed {
Some(managed) => Some(managed.execution_basis()?),
None => None,
};
// Source order and metadata come from configuration. Held directories,
// the private boundary and persistent stores are additional captures.
serde_json::to_value((
sources,
projects,
&self.private,
self.preferences.execution_basis()?,
managed,
))
.ok()
}
fn prepare_retirement(
&self,
) -> Option<Box<dyn maka_host_runtime::application::NativeRetirement>> {
Some(Box::new(()))
}
fn close(&self) -> BoxFuture<'static, Result<(), String>> {
// All reads retain their original call's resource group. No independent
// background scan, process, watcher or mutable catalog is owned here.
Box::pin(async { Ok(()) })
}
}
#[cfg(test)]
mod tests;