1use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Value};
8
9use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
10use crate::capability::handler::{CapabilityError, Context};
11use crate::capability::model::{
12 CachePolicy, Capability, CapabilityKind, Exposure, McpResource, Provenance, Stability,
13};
14use crate::capability::module::ModuleDescriptor;
15use crate::model::Object;
16use crate::{capability, module};
17
18use super::repository::REPOSITORY_URI;
19use super::{get, mcp, ObjectSummary, ObjectView};
20
21#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
25#[serde(deny_unknown_fields)]
26pub struct ListInput {
27 #[serde(default)]
30 pub kind: Option<String>,
31 #[serde(default)]
33 pub tag: Option<String>,
34}
35
36impl BenchmarkCases for ListInput {
37 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
38 let mut cases = vec![NamedCase::new("all", ListInput::default())];
39 if let Some((kind, _)) = ctx.index.kinds().into_iter().next() {
40 cases.push(NamedCase::new(
41 "first-kind",
42 ListInput {
43 kind: Some(kind.to_string()),
44 tag: None,
45 },
46 ));
47 }
48 cases
49 }
50}
51
52#[derive(Debug, Serialize, Deserialize, JsonSchema)]
53pub struct ObjectList {
55 pub count: usize,
57 pub objects: Vec<ObjectSummary>,
59}
60
61fn known_kind(ctx: &Context, kind: Option<&str>) -> Result<(), CapabilityError> {
63 let Some(kind) = kind else { return Ok(()) };
64 let kinds = ctx.index.kinds();
65 if kinds.contains_key(kind) {
66 Ok(())
67 } else {
68 Err(CapabilityError::InvalidInput(format!(
69 "kind '{kind}' is not among the kinds this repository has: {}",
70 kinds.keys().copied().collect::<Vec<_>>().join(", ")
71 )))
72 }
73}
74
75fn objects_list(ctx: &Context, input: ListInput) -> Result<ObjectList, CapabilityError> {
76 known_kind(ctx, input.kind.as_deref())?;
77 let objects: Vec<ObjectSummary> = ctx
78 .index
79 .objects
80 .iter()
81 .filter(|o| input.kind.as_deref().is_none_or(|k| o.kind == k))
82 .filter(|o| input.tag.as_deref().is_none_or(|t| o.tags().contains(&t)))
83 .map(ObjectSummary::of)
84 .collect();
85 Ok(ObjectList {
86 count: objects.len(),
87 objects,
88 })
89}
90
91#[derive(Debug)]
97pub enum Resolved<'a> {
98 Object(&'a Object),
100 Answer {
103 capability: &'a Capability,
105 resource: &'a McpResource,
107 value: Value,
109 },
110}
111
112impl Resolved<'_> {
113 pub fn media_type(&self) -> String {
115 match self {
116 Resolved::Object(o) => o.media_type.to_string(),
117 Resolved::Answer { .. } => "application/json".to_string(),
118 }
119 }
120
121 pub fn text(&self) -> String {
124 match self {
125 Resolved::Object(o) => o.content.clone(),
126 Resolved::Answer { value, .. } => pretty_json(value),
127 }
128 }
129}
130
131fn pretty_json(v: &Value) -> String {
133 serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
134}
135
136pub fn resolve<'a>(ctx: &'a Context, uri: &str) -> Result<Resolved<'a>, CapabilityError> {
173 let (capability, resource) = ctx
174 .registry
175 .by_mcp_uri(uri)
176 .ok_or_else(|| CapabilityError::NotFound(format!("unknown resource: {uri}")))?;
177 if !capability.kind.is_executable() {
178 return ctx.index.get(uri).map(Resolved::Object).ok_or_else(|| {
179 CapabilityError::Internal(format!(
180 "{uri}: the registry names {} and the index holds no such object",
181 capability.id
182 ))
183 });
184 }
185 let value = ctx
186 .execute(capability.id.as_str(), json!({}))
187 .map_err(|e| match e {
188 CapabilityError::Refused(reason) => CapabilityError::Refused(reason),
189 other => CapabilityError::Internal(format!(
190 "{uri}: {} did not answer: {other}",
191 capability.id
192 )),
193 })?;
194 Ok(Resolved::Answer {
195 capability,
196 resource,
197 value,
198 })
199}
200
201#[derive(Debug, Serialize, Deserialize, JsonSchema)]
204#[serde(deny_unknown_fields)]
205pub struct GetInput {
207 pub uri: String,
210}
211
212impl BenchmarkCases for GetInput {
213 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
214 let mut cases: Vec<NamedCase<Self>> = ctx
215 .index
216 .objects
217 .first()
218 .map(|o| {
219 vec![NamedCase::new(
220 "first-object",
221 GetInput { uri: o.uri.clone() },
222 )]
223 })
224 .unwrap_or_default();
225 cases.push(NamedCase::new(
226 "repository",
227 GetInput {
228 uri: REPOSITORY_URI.into(),
229 },
230 ));
231 cases
232 }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
239pub struct AnswerView {
240 pub uri: String,
242 pub id: String,
244 pub kind: CapabilityKind,
246 pub identity: String,
248 pub title: String,
250 #[serde(skip_serializing_if = "Option::is_none")]
251 pub description: Option<String>,
253 pub answer: Value,
256 pub provenance: Provenance,
258 pub media_type: String,
260 pub content: String,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
267#[serde(tag = "source", rename_all = "lowercase")]
268pub enum ResourceView {
269 Declarative(ObjectView),
271 Builtin(AnswerView),
273}
274
275fn objects_get(ctx: &Context, input: GetInput) -> Result<ResourceView, CapabilityError> {
276 let resolved = resolve(ctx, &input.uri)?;
277 let (media_type, content) = (resolved.media_type(), resolved.text());
278 Ok(match resolved {
279 Resolved::Object(o) => ResourceView::Declarative(ObjectView::of(o)),
280 Resolved::Answer {
281 capability,
282 resource,
283 value,
284 } => ResourceView::Builtin(AnswerView {
285 uri: input.uri,
286 id: capability.id.to_string(),
287 kind: capability.kind,
288 identity: resource.name.clone(),
289 title: capability.title.clone(),
290 description: (!capability.description.is_empty())
291 .then(|| capability.description.clone()),
292 answer: value,
293 provenance: capability.provenance.clone(),
294 media_type,
295 content,
296 }),
297 })
298}
299
300pub const SEARCH_DEFAULT_LIMIT: u64 = 20;
304pub const SEARCH_MAX_LIMIT: u64 = 200;
306
307#[derive(Debug, Serialize, Deserialize, JsonSchema)]
308#[serde(deny_unknown_fields)]
309pub struct SearchInput {
311 pub query: String,
313 #[serde(default)]
314 pub kind: Option<String>,
316 #[serde(default)]
318 #[schemars(range(min = 1, max = 200))]
319 pub limit: Option<u64>,
320}
321
322impl BenchmarkCases for SearchInput {
323 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
324 vec![
325 NamedCase::new(
326 "common-word",
327 SearchInput {
328 query: "the".into(),
329 kind: None,
330 limit: None,
331 },
332 ),
333 NamedCase::new(
334 "no-hit",
335 SearchInput {
336 query: "zqx-nothing-carries-this".into(),
337 kind: None,
338 limit: Some(5),
339 },
340 ),
341 ]
342 }
343}
344
345#[derive(Debug, Serialize, Deserialize, JsonSchema)]
346pub struct SearchHit {
348 #[serde(flatten)]
349 pub object: ObjectSummary,
351 #[serde(skip_serializing_if = "Option::is_none")]
353 pub snippet: Option<String>,
354}
355
356#[derive(Debug, Serialize, Deserialize, JsonSchema)]
357pub struct SearchResult {
359 pub query: String,
361 pub count: usize,
363 pub limit: u64,
365 pub hits: Vec<SearchHit>,
367}
368
369fn objects_search(ctx: &Context, input: SearchInput) -> Result<SearchResult, CapabilityError> {
370 if input.query.trim().is_empty() {
371 return Err(CapabilityError::Refused(
372 "argument 'query' is required and must not be blank".into(),
373 ));
374 }
375 known_kind(ctx, input.kind.as_deref())?;
376 let limit = input
377 .limit
378 .map(|l| l.clamp(1, SEARCH_MAX_LIMIT))
379 .unwrap_or(SEARCH_DEFAULT_LIMIT);
380 let needle = input.query.to_lowercase();
381 let mut hits = Vec::new();
382 for o in ctx
383 .index
384 .objects
385 .iter()
386 .filter(|o| input.kind.as_deref().is_none_or(|k| o.kind == k))
387 {
388 let head = [
389 o.identity.as_str(),
390 o.title.as_deref().unwrap_or(""),
391 o.description.as_deref().unwrap_or(""),
392 ]
393 .iter()
394 .any(|s| s.to_lowercase().contains(&needle));
395 let line = o
396 .content
397 .lines()
398 .find(|l| l.to_lowercase().contains(&needle));
399 if head || line.is_some() {
400 hits.push(SearchHit {
401 object: ObjectSummary::of(o),
402 snippet: line.map(|l| l.trim().chars().take(200).collect()),
403 });
404 if hits.len() as u64 == limit {
405 break;
406 }
407 }
408 }
409 Ok(SearchResult {
410 query: input.query,
411 count: hits.len(),
412 limit,
413 hits,
414 })
415}
416
417#[derive(Debug, Serialize, Deserialize, JsonSchema)]
420#[serde(deny_unknown_fields)]
421pub struct VerifyInput {
423 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub kind: Option<String>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub limit: Option<usize>,
429}
430
431impl BenchmarkCases for VerifyInput {
432 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
433 vec![NamedCase::new(
436 "bounded",
437 VerifyInput {
438 kind: None,
439 limit: Some(25),
440 },
441 )]
442 }
443}
444
445#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
446#[serde(rename_all = "snake_case")]
447pub enum ObjectStanding {
449 Current,
451 Changed,
453 Missing,
455 Unreadable,
457}
458
459#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
460#[serde(rename_all = "snake_case")]
461pub enum Comparison {
469 Content,
471 Size,
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
476pub struct DriftedObject {
478 pub path: String,
480 pub standing: ObjectStanding,
482 pub comparison: Comparison,
484 pub objects: usize,
486 pub example_uri: String,
488 pub detail: String,
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
493pub struct VerifyReport {
495 pub files: usize,
497 pub current: usize,
499 pub drifted: usize,
501 pub objects: usize,
503 pub compared_by_content: usize,
505 pub fingerprint: String,
507 pub index_is_current: bool,
510 pub findings: Vec<DriftedObject>,
512}
513
514fn objects_verify(ctx: &Context, input: VerifyInput) -> Result<VerifyReport, CapabilityError> {
526 if let Some(kind) = &input.kind {
527 if !ctx.index.objects.iter().any(|o| &o.kind == kind) {
528 return Err(CapabilityError::InvalidInput(format!(
529 "no objects of kind '{kind}'; the kinds are listed by objects.list"
530 )));
531 }
532 }
533 let mut files: Vec<FileSubject> = Vec::new();
535 let mut seen: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
536 for object in ctx
537 .index
538 .objects
539 .iter()
540 .filter(|o| input.kind.as_ref().is_none_or(|k| &o.kind == k))
541 {
542 let path = object.provenance.path.as_str();
543 match seen.get(path) {
544 Some(at) => files[*at].objects += 1,
545 None => {
546 seen.insert(path, files.len());
547 files.push(FileSubject {
548 path: path.to_string(),
549 bytes: object.provenance.bytes,
550 objects: 1,
551 example_uri: object.uri.clone(),
552 content: object
555 .provenance
556 .member
557 .is_none()
558 .then(|| object.content.clone()),
559 });
560 }
561 }
562 }
563 let subjects: Vec<FileSubject> = files
564 .into_iter()
565 .take(input.limit.unwrap_or(usize::MAX))
566 .collect();
567
568 let root = std::path::Path::new(&ctx.index.repository.root);
569 let total = subjects.len() as u64;
570 let p = &ctx.progress;
571 p.step("read", &format!("Reading {total} file(s) of the layer"));
572
573 let mut findings = Vec::new();
574 let mut current = 0usize;
575 let mut objects = 0usize;
576 let mut by_content = 0usize;
577 for (n, subject) in subjects.iter().enumerate() {
578 if p.cancelled() {
579 p.step_done(
580 "read",
581 false,
582 Some(format!("cancelled after {n} of {total}")),
583 );
584 return Err(p.cancellation());
585 }
586 objects += subject.objects;
587 let comparison = if subject.content.is_some() {
588 by_content += 1;
589 Comparison::Content
590 } else {
591 Comparison::Size
592 };
593 let standing = match std::fs::read_to_string(root.join(&subject.path)) {
594 Ok(text) => match &subject.content {
595 Some(known) if &text == known => ObjectStanding::Current,
596 Some(_) => ObjectStanding::Changed,
597 None if text.len() as u64 == subject.bytes => ObjectStanding::Current,
598 None => ObjectStanding::Changed,
599 },
600 Err(e) if e.kind() == std::io::ErrorKind::NotFound => ObjectStanding::Missing,
601 Err(_) => ObjectStanding::Unreadable,
602 };
603 match standing {
604 ObjectStanding::Current => current += 1,
605 other => {
606 let detail = match other {
607 ObjectStanding::Changed => match comparison {
608 Comparison::Content => {
609 "the file has changed since this process built its index".to_string()
610 }
611 Comparison::Size => format!(
612 "the file's size is no longer the {} bytes this process read",
613 subject.bytes
614 ),
615 },
616 ObjectStanding::Missing => "the file is gone".to_string(),
617 _ => "the file could not be read".to_string(),
618 };
619 p.diagnostic(crate::execution::ExecutionDiagnostic {
620 severity: crate::model::Severity::Warning,
621 code: format!("layer_file_{}", enum_word(&other)),
622 summary: format!("{}: {detail}", subject.path),
623 detail: Some(format!(
624 "{} object(s) of the index came from it, such as {}",
625 subject.objects, subject.example_uri
626 )),
627 suggestion: Some("restart this server to serve the layer as it is now".into()),
628 });
629 findings.push(DriftedObject {
630 path: subject.path.clone(),
631 standing: other,
632 comparison,
633 objects: subject.objects,
634 example_uri: subject.example_uri.clone(),
635 detail,
636 });
637 }
638 }
639 p.progress(
640 n as u64 + 1,
641 Some(total),
642 format!("{} file(s) read; last {}", n + 1, subject.path),
643 );
644 }
645 p.step_done(
646 "read",
647 findings.is_empty(),
648 Some(format!("{current} current, {} drifted", findings.len())),
649 );
650 Ok(VerifyReport {
651 files: subjects.len(),
652 current,
653 drifted: findings.len(),
654 objects,
655 compared_by_content: by_content,
656 fingerprint: ctx.index.fingerprint.clone(),
657 index_is_current: findings.is_empty(),
658 findings,
659 })
660}
661
662struct FileSubject {
664 path: String,
665 bytes: u64,
666 objects: usize,
667 example_uri: String,
668 content: Option<String>,
670}
671
672fn enum_word<T: Serialize>(value: &T) -> String {
674 serde_json::to_value(value)
675 .ok()
676 .and_then(|v| v.as_str().map(str::to_string))
677 .unwrap_or_default()
678}
679
680pub fn module() -> ModuleDescriptor {
682 module! {
683 id: "objects",
684 title: "Objects",
685 description: "The declarative objects of the repository's AI layer: rules, prompts, profiles, policy, documents, milestones, issues, claims, and whatever kinds the repository adds; listed, read by URI, and searched.",
686 stability: Stability::BehaviorallyVerified,
687 capabilities: [
688 capability! {
689 id: "objects.list",
690 title: "List objects",
691 description: "List the declarative objects of the repository's AI layer, optionally by kind or tag.",
692 input: ListInput,
693 output: ObjectList,
694 stability: Stability::BehaviorallyVerified,
695 exposure: Exposure { mcp: mcp("majordomus_list"), http: get("/api/v1/objects"), cli: None },
696 tags: ["objects"],
697 handler: objects_list,
698 },
699 capability! {
700 id: "objects.get",
701 title: "Get one object",
702 description: "One object by URI (majordomus://<kind>/<identity>): metadata, provenance and content; a URI a query projects (majordomus://repository) answers that query as a JSON document. The same resolution serves the MCP resource read.",
703 input: GetInput,
704 output: ResourceView,
705 stability: Stability::BehaviorallyVerified,
706 exposure: Exposure { mcp: mcp("majordomus_get"), http: get("/api/v1/object"), cli: None },
707 tags: ["objects"],
708 handler: objects_get,
709 },
710 capability! {
711 id: "objects.search",
712 title: "Search objects",
713 description: "Case-insensitive substring search over identities, titles, descriptions and content.",
714 input: SearchInput,
715 output: SearchResult,
716 stability: Stability::BehaviorallyVerified,
717 exposure: Exposure { mcp: mcp("majordomus_search"), http: get("/api/v1/search"), cli: None },
718 tags: ["objects"],
719 cache: CachePolicy::Process { max_entries: 64, ttl_seconds: None },
720 handler: objects_search,
721 },
722 capability! {
723 id: "objects.verify",
724 title: "Verify the index against the working tree",
725 description: "Read every file the layer was built from and compare it with what this process is serving. The index is built once at start-up and kept, which is what makes every other request cost nothing and what makes a file edited afterwards be served as it was; this is how a running server says whether that has happened, without being restarted to find out. A file that is one object is compared byte for byte; a collection file, whose objects the index keeps as members rather than as text, is compared by size, and every finding says which comparison was made. It reads every file of the layer, so it reports its progress file by file and stops when it is asked to.",
726 input: VerifyInput,
727 output: VerifyReport,
728 stability: Stability::BehaviorallyVerified,
729 exposure: Exposure { mcp: mcp("majordomus_verify_objects"), http: get("/api/v1/objects/verify"), cli: None },
730 tags: ["objects", "diagnostic"],
731 handler: objects_verify,
732 }
733 .cancellable(),
734 ],
735 }
736}