1use runx_contracts::maturity::MaturityTier;
5use runx_contracts::{JsonObject, JsonValue, sha256_hex};
6use runx_parser::{
7 SkillRunnerManifest, ValidatedSkill, parse_runner_manifest_yaml, parse_skill_markdown,
8 validate_runner_manifest, validate_skill,
9};
10use serde::Deserialize;
11
12use super::super::package_files::{normalize_registry_package_files, registry_package_digest};
13use super::super::scopes::required_scopes_from_skill_and_runner;
14use super::super::types::{
15 RegistryAttestation, RegistryPackageFile, RegistryPublisher, RegistrySkillVersion,
16 RegistrySourceMetadata, TrustTier,
17};
18use super::{IngestSkillOptions, LocalRegistryError, build_skill_id};
19use crate::registry::local::trust::{
20 build_publisher_attestations, build_source_attestations, merge_registry_attestations,
21 normalize_attestations,
22};
23use crate::registry::local::util::{
24 missing_field, now_iso8601, required_string, validate_publisher, validate_source_metadata,
25};
26
27pub fn build_registry_skill_version(
28 markdown: &str,
29 options: &IngestSkillOptions,
30) -> Result<RegistrySkillVersion, LocalRegistryError> {
31 let raw = parse_skill_markdown(markdown)?;
32 let skill = validate_skill(raw)?;
33 let digest = sha256_hex(markdown.as_bytes());
34 let binding = build_binding_artifact(&skill, options.profile_document.as_deref())?;
35 let package_files = normalize_package_files(options.package_files.clone())?;
36 let package_digest = registry_package_digest(&package_files);
37 let catalog = registry_catalog(binding.manifest.as_ref());
38 let defaults = registry_version_defaults(
39 &digest,
40 binding.digest.as_deref(),
41 package_digest.as_deref(),
42 options,
43 );
44 let manifest = binding.manifest.as_ref();
45 let skill_id = build_skill_id(&defaults.owner, &skill.name)?;
46 Ok(RegistrySkillVersion {
47 skill_id,
48 owner: defaults.owner,
49 name: skill.name.clone(),
50 description: skill.description.clone(),
51 category: skill.runx_category.clone(),
52 source_category: skill.category.clone(),
53 version: defaults.version,
54 digest,
55 signed_manifest: None,
56 markdown: markdown.to_owned(),
57 profile_document: options.profile_document.clone(),
58 profile_digest: binding.digest,
59 package_files,
60 package_digest,
61 runner_names: binding.runner_names,
62 source_type: skill.source.source_type.as_str().to_owned(),
63 trust_tier: defaults.trust_tier,
64 maturity: MaturityTier::Alpha,
67 catalog_kind: Some(catalog.kind.as_str().to_owned()),
68 catalog_audience: Some(catalog.audience.as_str().to_owned()),
69 catalog_visibility: Some(catalog.visibility.as_str().to_owned()),
70 source_metadata: defaults.source_metadata,
71 attestations: defaults.attestations,
72 required_scopes: registry_required_scopes(&skill, manifest)?,
73 runtime: registry_runtime(&skill, manifest),
74 auth: skill.auth.clone(),
75 risk: registry_risk(&skill),
76 runx: skill.runx.clone(),
77 tags: registry_tags(&skill, manifest),
78 publisher: defaults.publisher,
79 created_at: defaults.created_at,
80 updated_at: now_iso8601(),
81 })
82}
83
84struct RegistryVersionDefaults {
85 owner: String,
86 created_at: String,
87 publisher: RegistryPublisher,
88 trust_tier: TrustTier,
89 version: String,
90 source_metadata: Option<RegistrySourceMetadata>,
91 attestations: Vec<RegistryAttestation>,
92}
93
94fn registry_version_defaults(
95 digest: &str,
96 profile_digest: Option<&str>,
97 package_digest: Option<&str>,
98 options: &IngestSkillOptions,
99) -> RegistryVersionDefaults {
100 let owner = options.owner.clone().unwrap_or_else(|| "local".to_owned());
101 let created_at = options.created_at.clone().unwrap_or_else(now_iso8601);
102 let publisher = options
103 .publisher
104 .clone()
105 .unwrap_or_else(|| default_registry_publisher(&owner));
106 let trust_tier = options
107 .trust_tier
108 .clone()
109 .unwrap_or_else(|| derive_registry_trust_tier(&owner, None));
110 let version = options.version.clone().unwrap_or_else(|| {
111 let seed = default_registry_version_seed(digest, profile_digest, package_digest);
112 format!("sha-{}", seed.chars().take(12).collect::<String>())
113 });
114 let source_metadata = options.source_metadata.clone();
115 let attestations = merge_registry_attestations(vec![
116 build_publisher_attestations(&publisher, &trust_tier, &created_at),
117 build_source_attestations(source_metadata.as_ref(), &created_at),
118 options.attestations.clone(),
119 ]);
120 RegistryVersionDefaults {
121 owner,
122 created_at,
123 publisher,
124 trust_tier,
125 version,
126 source_metadata,
127 attestations,
128 }
129}
130
131pub(super) fn registry_catalog(
132 manifest: Option<&SkillRunnerManifest>,
133) -> runx_parser::CatalogMetadata {
134 manifest
135 .and_then(|manifest| manifest.catalog.clone())
136 .unwrap_or(runx_parser::CatalogMetadata {
137 kind: runx_parser::CatalogKind::Skill,
138 audience: runx_parser::CatalogAudience::Public,
139 visibility: runx_parser::CatalogVisibility::Public,
140 role: runx_parser::CatalogRole::Context,
141 canonical_skill: None,
142 provider: None,
143 runtime_path: None,
144 part_of: Vec::new(),
145 })
146}
147
148pub(super) fn registry_required_scopes(
149 skill: &ValidatedSkill,
150 manifest: Option<&SkillRunnerManifest>,
151) -> Result<Vec<String>, LocalRegistryError> {
152 required_scopes_from_skill_and_runner(skill, manifest).map_err(|error| {
153 LocalRegistryError::InvalidSkillManifest {
154 field: error.field,
155 message: error.message,
156 }
157 })
158}
159
160pub(super) fn registry_runtime(
161 skill: &ValidatedSkill,
162 manifest: Option<&SkillRunnerManifest>,
163) -> Option<JsonValue> {
164 skill
165 .runtime
166 .clone()
167 .or_else(|| record_field(skill.runx.as_ref(), "runtime"))
168 .or_else(|| extract_runner_runtime(manifest))
169}
170
171pub(super) fn registry_risk(skill: &ValidatedSkill) -> Option<JsonValue> {
172 skill
173 .risk
174 .clone()
175 .or_else(|| record_field(skill.runx.as_ref(), "risk"))
176}
177
178pub(super) fn registry_tags(
179 skill: &ValidatedSkill,
180 manifest: Option<&SkillRunnerManifest>,
181) -> Vec<String> {
182 unique(
183 extract_tags(skill)
184 .into_iter()
185 .chain(skill.runx_category.clone())
186 .chain(extract_runner_tags(manifest))
187 .collect(),
188 )
189}
190
191pub fn normalize_registry_skill_version(
194 payload: RegistrySkillVersionPayload,
195) -> Result<RegistrySkillVersion, LocalRegistryError> {
196 let governance = normalize_registry_version_governance(&payload)?;
197 let package_files = normalize_package_files(payload.package_files.unwrap_or_default())?;
198 let computed_package_digest = registry_package_digest(&package_files);
199 if let (Some(declared), Some(computed)) = (&payload.package_digest, &computed_package_digest) {
200 if declared != computed {
201 return Err(LocalRegistryError::InvalidVersionPayload {
202 field: "registry_version.package_digest".to_owned(),
203 message: format!(
204 "declared digest {declared} does not match package files {computed}"
205 ),
206 });
207 }
208 }
209 if payload.package_digest.is_some() && computed_package_digest.is_none() {
210 return Err(LocalRegistryError::InvalidVersionPayload {
211 field: "registry_version.package_digest".to_owned(),
212 message: "declared without package_files".to_owned(),
213 });
214 }
215 let markdown = required_string(payload.markdown, "registry_version.markdown")?;
216 let derived_categories = derive_categories_from_markdown(&markdown);
217 let category = payload.category.or(derived_categories.runx_category);
218 let source_category = payload.source_category.or(derived_categories.category);
219
220 Ok(RegistrySkillVersion {
221 skill_id: required_string(payload.skill_id, "registry_version.skill_id")?,
222 owner: governance.owner,
223 name: required_string(payload.name, "registry_version.name")?,
224 description: payload.description,
225 category,
226 source_category,
227 version: required_string(payload.version, "registry_version.version")?,
228 digest: required_string(payload.digest, "registry_version.digest")?,
229 signed_manifest: payload.signed_manifest,
230 markdown,
231 profile_document: payload.profile_document,
232 profile_digest: payload.profile_digest,
233 package_files,
234 package_digest: payload.package_digest.or(computed_package_digest),
235 runner_names: payload.runner_names.unwrap_or_default(),
236 source_type: required_string(payload.source_type, "registry_version.source_type")?,
237 trust_tier: governance.trust_tier,
238 maturity: payload.maturity.unwrap_or_default(),
240 catalog_kind: Some(governance.catalog.kind.as_str().to_owned()),
241 catalog_audience: Some(governance.catalog.audience.as_str().to_owned()),
242 catalog_visibility: Some(governance.catalog.visibility.as_str().to_owned()),
243 source_metadata: governance.source_metadata,
244 attestations: governance.attestations,
245 required_scopes: payload.required_scopes.unwrap_or_default(),
246 runtime: payload.runtime,
247 auth: payload.auth,
248 risk: payload.risk,
249 runx: payload.runx,
250 tags: payload.tags.unwrap_or_default(),
251 publisher: governance.publisher,
252 updated_at: governance.updated_at,
253 created_at: governance.created_at,
254 })
255}
256
257struct DerivedCategories {
258 category: Option<String>,
259 runx_category: Option<String>,
260}
261
262fn derive_categories_from_markdown(markdown: &str) -> DerivedCategories {
263 let Some(skill) = parse_skill_markdown(markdown)
264 .ok()
265 .and_then(|raw| validate_skill(raw).ok())
266 else {
267 return DerivedCategories {
268 category: None,
269 runx_category: None,
270 };
271 };
272 DerivedCategories {
273 category: skill.category,
274 runx_category: skill.runx_category,
275 }
276}
277
278struct NormalizedRegistryVersionGovernance {
279 owner: String,
280 created_at: String,
281 publisher: RegistryPublisher,
282 trust_tier: TrustTier,
283 source_metadata: Option<RegistrySourceMetadata>,
284 attestations: Vec<RegistryAttestation>,
285 catalog: runx_parser::CatalogMetadata,
286 updated_at: String,
287}
288
289fn normalize_registry_version_governance(
290 payload: &RegistrySkillVersionPayload,
291) -> Result<NormalizedRegistryVersionGovernance, LocalRegistryError> {
292 let owner = required_string(payload.owner.clone(), "registry_version.owner")?;
293 let created_at = required_string(payload.created_at.clone(), "registry_version.created_at")?;
294 let publisher = validate_publisher(
295 payload
296 .publisher
297 .clone()
298 .ok_or_else(|| missing_field("registry_version.publisher"))?,
299 "registry_version.publisher",
300 )?;
301 let trust_tier = payload.trust_tier.clone().unwrap_or(TrustTier::Community);
302 let source_metadata = normalize_source_metadata(payload.source_metadata.clone())?;
303 let attestations = normalize_attestations(
304 payload.attestations.clone().unwrap_or_default(),
305 source_metadata.as_ref(),
306 &publisher,
307 &trust_tier,
308 &created_at,
309 );
310 let catalog = normalize_registry_catalog(
311 payload.catalog_kind.as_deref(),
312 payload.catalog_audience.as_deref(),
313 payload.catalog_visibility.as_deref(),
314 );
315 let updated_at = payload
316 .updated_at
317 .as_ref()
318 .filter(|value| !value.is_empty())
319 .cloned()
320 .unwrap_or_else(|| created_at.clone());
321 Ok(NormalizedRegistryVersionGovernance {
322 owner,
323 created_at,
324 publisher,
325 trust_tier,
326 source_metadata,
327 attestations,
328 catalog,
329 updated_at,
330 })
331}
332
333pub(super) fn normalize_source_metadata(
334 source_metadata: Option<RegistrySourceMetadata>,
335) -> Result<Option<RegistrySourceMetadata>, LocalRegistryError> {
336 source_metadata.map(validate_source_metadata).transpose()
337}
338
339pub(super) fn normalize_registry_catalog(
340 kind: Option<&str>,
341 audience: Option<&str>,
342 visibility: Option<&str>,
343) -> runx_parser::CatalogMetadata {
344 runx_parser::CatalogMetadata {
345 kind: match kind {
346 Some("graph") => runx_parser::CatalogKind::Graph,
347 _ => runx_parser::CatalogKind::Skill,
348 },
349 audience: match audience {
350 Some("builder") => runx_parser::CatalogAudience::Builder,
351 Some("operator") => runx_parser::CatalogAudience::Operator,
352 Some("system") => runx_parser::CatalogAudience::System,
353 _ => runx_parser::CatalogAudience::Public,
354 },
355 visibility: match visibility {
356 Some("internal") => runx_parser::CatalogVisibility::Internal,
357 _ => runx_parser::CatalogVisibility::Public,
358 },
359 role: runx_parser::CatalogRole::Context,
360 canonical_skill: None,
361 provider: None,
362 runtime_path: None,
363 part_of: Vec::new(),
364 }
365}
366
367#[derive(Clone, Debug, Deserialize)]
368pub struct RegistrySkillVersionPayload {
369 skill_id: Option<String>,
370 owner: Option<String>,
371 name: Option<String>,
372 description: Option<String>,
373 category: Option<String>,
374 source_category: Option<String>,
375 version: Option<String>,
376 digest: Option<String>,
377 signed_manifest: Option<super::super::types::RegistrySignedManifest>,
378 markdown: Option<String>,
379 profile_document: Option<String>,
380 profile_digest: Option<String>,
381 package_files: Option<Vec<RegistryPackageFile>>,
382 package_digest: Option<String>,
383 runner_names: Option<Vec<String>>,
384 source_type: Option<String>,
385 trust_tier: Option<TrustTier>,
386 maturity: Option<MaturityTier>,
387 catalog_kind: Option<String>,
388 catalog_audience: Option<String>,
389 catalog_visibility: Option<String>,
390 source_metadata: Option<RegistrySourceMetadata>,
391 attestations: Option<Vec<RegistryAttestation>>,
392 required_scopes: Option<Vec<String>>,
393 runtime: Option<JsonValue>,
394 auth: Option<JsonValue>,
395 risk: Option<JsonValue>,
396 runx: Option<JsonObject>,
397 tags: Option<Vec<String>>,
398 publisher: Option<RegistryPublisher>,
399 created_at: Option<String>,
400 updated_at: Option<String>,
401}
402
403struct BindingArtifact {
404 digest: Option<String>,
405 runner_names: Vec<String>,
406 manifest: Option<SkillRunnerManifest>,
407}
408
409fn build_binding_artifact(
410 skill: &ValidatedSkill,
411 profile_document: Option<&str>,
412) -> Result<BindingArtifact, LocalRegistryError> {
413 let Some(profile_document) = profile_document else {
414 return Ok(BindingArtifact {
415 digest: None,
416 runner_names: Vec::new(),
417 manifest: None,
418 });
419 };
420 let manifest = validate_runner_manifest(parse_runner_manifest_yaml(profile_document)?)?;
421 if let Some(manifest_skill) = &manifest.skill {
422 if manifest_skill != &skill.name {
423 return Err(LocalRegistryError::InvalidVersionPayload {
424 field: "profile_document.skill".to_owned(),
425 message: format!(
426 "runner manifest skill '{manifest_skill}' does not match skill '{}'",
427 skill.name
428 ),
429 });
430 }
431 }
432 Ok(BindingArtifact {
433 digest: Some(sha256_hex(profile_document.as_bytes())),
434 runner_names: manifest.runners.keys().cloned().collect(),
435 manifest: Some(manifest),
436 })
437}
438
439pub(super) fn default_registry_version_seed(
440 markdown_digest: &str,
441 profile_digest: Option<&str>,
442 package_digest: Option<&str>,
443) -> String {
444 match (profile_digest, package_digest) {
445 (None, None) => markdown_digest.to_owned(),
446 _ => sha256_hex(
447 format!(
448 "{{\"markdown_digest\":\"{markdown_digest}\",\"package_digest\":\"{}\",\"profile_digest\":\"{}\"}}",
449 package_digest.unwrap_or(""),
450 profile_digest.unwrap_or("")
451 )
452 .as_bytes(),
453 ),
454 }
455}
456
457fn normalize_package_files(
458 files: Vec<RegistryPackageFile>,
459) -> Result<Vec<RegistryPackageFile>, LocalRegistryError> {
460 normalize_registry_package_files(files).map_err(|message| {
461 LocalRegistryError::InvalidVersionPayload {
462 field: "registry_version.package_files".to_owned(),
463 message,
464 }
465 })
466}
467
468pub(super) fn default_registry_publisher(owner: &str) -> RegistryPublisher {
469 RegistryPublisher {
470 kind: if owner == "runx" {
471 "organization".to_owned()
472 } else {
473 "publisher".to_owned()
474 },
475 id: owner.to_owned(),
476 handle: Some(owner.to_owned()),
477 display_name: None,
478 }
479}
480
481pub(super) fn derive_registry_trust_tier(
482 _owner: &str,
483 trust_tier: Option<&TrustTier>,
484) -> TrustTier {
485 trust_tier.cloned().unwrap_or(TrustTier::Community)
486}
487
488pub(super) fn extract_runner_runtime(manifest: Option<&SkillRunnerManifest>) -> Option<JsonValue> {
489 let manifest = manifest?;
490 let runners = manifest
491 .runners
492 .values()
493 .filter(|runner| runner.runtime.is_some())
494 .map(|runner| JsonValue::String(runner.name.clone()))
495 .collect::<Vec<_>>();
496 if runners.is_empty() {
497 None
498 } else {
499 Some(JsonValue::Object(
500 [("runners".to_owned(), JsonValue::Array(runners))].into(),
501 ))
502 }
503}
504
505pub(super) fn extract_runner_tags(manifest: Option<&SkillRunnerManifest>) -> Vec<String> {
506 let Some(manifest) = manifest else {
507 return Vec::new();
508 };
509 unique(
510 manifest
511 .runners
512 .values()
513 .flat_map(|runner| record_array_field_from_object(runner.runx.as_ref(), "tags"))
514 .collect(),
515 )
516}
517
518pub(super) fn extract_tags(skill: &ValidatedSkill) -> Vec<String> {
519 unique(record_array_field_from_object(skill.runx.as_ref(), "tags"))
520}
521
522pub(super) fn record_array_field_from_object(
523 value: Option<&JsonObject>,
524 field: &str,
525) -> Vec<String> {
526 let Some(record) = value else {
527 return Vec::new();
528 };
529 let Some(JsonValue::Array(values)) = record.get(field) else {
530 return Vec::new();
531 };
532 values
533 .iter()
534 .filter_map(|value| match value {
535 JsonValue::String(value) if !value.is_empty() => Some(value.clone()),
536 _ => None,
537 })
538 .collect()
539}
540
541pub(super) fn record_field(value: Option<&JsonObject>, field: &str) -> Option<JsonValue> {
542 value.and_then(|record| record.get(field).cloned())
543}
544
545pub(super) fn unique(values: Vec<String>) -> Vec<String> {
546 let mut unique_values = Vec::new();
547 for value in values {
548 if !unique_values.contains(&value) {
549 unique_values.push(value);
550 }
551 }
552 unique_values
553}