1use std::collections::{BTreeMap, BTreeSet, HashMap};
10use std::sync::{Arc, Mutex};
11
12use async_trait::async_trait;
13use ikigai_core::{
14 ActionSpec, ArgRef, ArgSpec, Description, Endpoint, EndpointSpace, Error, Exact, Invocation,
15 Iri, ReprType, Representation, Request, Resolution, Result, Scope, Space, SpaceEntry, Topology,
16 UriTemplate, Verb,
17};
18use serde::{Deserialize, Serialize};
19
20use crate::authority::{
21 self, cap_delete, cap_read, cap_run, cap_write, Ceiling, CeilingPolicy, PrincipalStamper,
22 CAP_ANY, CAP_DELETE, CAP_LISP, CAP_READ, CAP_READ_PUBLIC, CAP_RUN, CAP_RUN_PUBLIC, CAP_WRITE,
23};
24use crate::backend::Backend;
25use crate::model::{
26 self, Event, Head, Language, Outcome, Run, State, Version, MAX_RECORDED_RESULT, SCHEMA,
27};
28use crate::name::{self, CATALOG_IRI, EVAL_IRI};
29use crate::plan;
30use crate::sparql::{self, Analysis, Form, Parameter, SparqlDoor, Value, CAP_READ_GRAPH};
31
32const PLAIN: &str = "text/plain";
33const JSON: &str = "application/json";
34const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
35const XSD_BOOLEAN: &str = "http://www.w3.org/2001/XMLSchema#boolean";
36const XSD_INTEGER: &str = "http://www.w3.org/2001/XMLSchema#integer";
37const TURTLE: &str = "text/turtle";
38const READ_FACES: [&str; 2] = [PLAIN, JSON];
40const RECORD_FACES: [&str; 3] = [PLAIN, JSON, TURTLE];
42
43#[derive(Clone)]
50#[non_exhaustive]
51pub struct SpaceConfig {
52 backend: Arc<dyn Backend>,
53 ceiling: CeilingPolicy,
54 principal: PrincipalStamper,
55 sparql: Option<SparqlDoor>,
56 on_change: Option<ChangeHook>,
57 described: Arc<Mutex<HashMap<String, Option<PerScript>>>>,
60}
61
62pub type ChangeHook = Arc<dyn Fn(&str) + Send + Sync>;
65
66impl SpaceConfig {
67 pub fn new(backend: Arc<dyn Backend>, ceiling: CeilingPolicy) -> SpaceConfig {
69 SpaceConfig {
70 backend,
71 ceiling,
72 principal: authority::unstamped(),
73 sparql: None,
74 on_change: None,
75 described: Arc::new(Mutex::new(HashMap::new())),
76 }
77 }
78
79 pub fn principal(mut self, stamper: PrincipalStamper) -> SpaceConfig {
86 self.principal = stamper;
87 self
88 }
89
90 pub fn sparql(mut self, door: SparqlDoor) -> SpaceConfig {
94 self.sparql = Some(door);
95 self
96 }
97
98 pub fn on_change(mut self, hook: ChangeHook) -> SpaceConfig {
109 self.on_change = Some(hook);
110 self
111 }
112
113 fn changed(&self, name: &str) {
115 self.described
116 .lock()
117 .expect("the described-script memo")
118 .remove(name);
119 if let Some(hook) = &self.on_change {
120 hook(name);
121 }
122 }
123}
124
125pub fn space(config: SpaceConfig) -> ScriptSpace {
132 let shared = Arc::new(config);
133 let template = |t: &str| UriTemplate::parse(t).expect("a constant template");
134 let inner = EndpointSpace::new()
135 .bind(Exact::new(EVAL_IRI), EvalEndpoint)
136 .bind(
137 Exact::new(CATALOG_IRI),
138 CatalogEndpoint {
139 shared: Arc::clone(&shared),
140 },
141 )
142 .bind(
143 template("urn:script:{name}:version:{digest}"),
144 VersionEndpoint {
145 shared: Arc::clone(&shared),
146 },
147 )
148 .bind(
149 template("urn:script:{name}:run:{id}"),
150 RunEndpoint {
151 shared: Arc::clone(&shared),
152 },
153 )
154 .bind(
155 template("urn:script:{name}:compiled"),
156 CompiledEndpoint {
157 shared: Arc::clone(&shared),
158 },
159 )
160 .bind(
161 template("urn:script:{name}:result"),
162 ResultEndpoint {
163 shared: Arc::clone(&shared),
164 },
165 )
166 .bind(
167 template("urn:script:{name}:runs"),
168 RunsEndpoint {
169 shared: Arc::clone(&shared),
170 },
171 )
172 .bind(
173 template("urn:script:{name}"),
174 ScriptEndpoint {
175 shared: Arc::clone(&shared),
176 },
177 );
178 ScriptSpace { inner, shared }
179}
180
181fn holds(inv: &Invocation<'_>, scope: &str) -> bool {
186 inv.capability.allows(scope)
187}
188
189fn is_public(head: &Head) -> bool {
191 head.public && head.state == State::Published
192}
193
194fn readable_head(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Result<Option<Head>> {
200 gated_head(inv, shared, name, &[cap_read(name)], &[CAP_READ_PUBLIC])
201}
202
203fn gated_head(
206 inv: &Invocation<'_>,
207 shared: &SpaceConfig,
208 name: &str,
209 exact: &[String],
210 public: &[&str],
211) -> Result<Option<Head>> {
212 if exact.iter().any(|s| holds(inv, s)) {
213 return shared.backend.head(name);
214 }
215 if let Ok(Some(head)) = shared.backend.head(name) {
216 if is_public(&head) && public.iter().any(|s| holds(inv, s)) {
217 return Ok(Some(head));
218 }
219 }
220 Err(Error::Denied(format!(
221 "this capability holds none of {} — nor, for a public script, {}. A script grant \
222 names exactly one script",
223 exact.join(", "),
224 public.join(", ")
225 )))
226}
227
228fn require(inv: &Invocation<'_>, scope: &str, doing: &str) -> Result<()> {
229 if holds(inv, scope) {
230 Ok(())
231 } else {
232 Err(Error::Denied(format!(
233 "{doing} needs `{scope}`, which this capability does not hold. A script grant \
234 names exactly one script"
235 )))
236 }
237}
238
239fn found(head: Option<Head>, name: &str) -> Result<Head> {
240 head.ok_or_else(|| Error::NotFound(format!("no script is published at urn:script:{name}")))
241}
242
243#[derive(Clone, Copy, Debug, PartialEq, Eq)]
256enum Sight {
257 Everyone,
260 Author,
265 Hidden,
269}
270
271impl Sight {
272 fn cacheable(self) -> bool {
274 self == Sight::Everyone
275 }
276}
277
278fn ever_published(head: &Head, digest: &str) -> bool {
281 (head.version == digest && head.state == State::Published)
282 || head
283 .history
284 .iter()
285 .any(|event| event.action == "publish" && event.version == digest)
286}
287
288fn sight(inv: &Invocation<'_>, shared: &SpaceConfig, head: &Head, digest: &str) -> Sight {
294 if ever_published(head, digest) || inv.capability.is_root() {
295 return Sight::Everyone;
296 }
297 let me = (shared.principal)(inv);
298 let wrote_it = authority::is_identity(&me)
299 && ((head.version == digest && head.publisher == me)
300 || head
301 .history
302 .iter()
303 .any(|e| e.action == "draft" && e.version == digest && e.principal == me));
304 if wrote_it {
305 Sight::Author
306 } else {
307 Sight::Hidden
308 }
309}
310
311fn absent(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Error {
315 let mut message = format!("no script is published at urn:script:{name}");
316 if (shared.principal)(inv) == authority::UNSTAMPED && !inv.capability.is_root() {
317 message.push_str(
318 " (this host stamps no principal, so no caller can be shown to be a draft's \
319 author: a draft is visible to root alone until it is published)",
320 );
321 }
322 Error::NotFound(message)
323}
324
325fn seen_head(
328 inv: &Invocation<'_>,
329 shared: &SpaceConfig,
330 name: &str,
331 gated: Option<Head>,
332) -> Result<(Head, Sight)> {
333 let Some(head) = gated else {
334 return Err(absent(inv, shared, name));
335 };
336 match sight(inv, shared, &head, &head.version) {
337 Sight::Hidden => Err(absent(inv, shared, name)),
338 seen => Ok((head, seen)),
339 }
340}
341
342fn cached_if(repr: Representation, seen: Sight) -> Representation {
344 if seen.cacheable() {
345 repr.cacheable()
346 } else {
347 repr
348 }
349}
350
351fn head_version(shared: &SpaceConfig, head: &Head) -> Result<Version> {
352 shared
353 .backend
354 .version(&head.name, &head.version)?
355 .ok_or_else(|| {
356 Error::Endpoint(format!(
357 "urn:script:{} points at version {}, which its backend does not hold",
358 head.name, head.version
359 ))
360 })
361}
362
363fn optional<'a>(inv: &'a Invocation<'_>, name: &str) -> Result<Option<&'a str>> {
369 match inv.inline_str(name) {
370 Ok(value) => Ok(Some(value)),
371 Err(Error::MissingArgument(_)) => Ok(None),
372 Err(other) => Err(other),
373 }
374}
375
376fn parse_bool(name: &str, text: &str) -> Result<bool> {
377 match text.trim() {
378 "true" => Ok(true),
379 "false" => Ok(false),
380 other => Err(Error::InvalidArgument {
381 name: name.to_string(),
382 detail: format!("`{other}` is not `true` or `false`"),
383 }),
384 }
385}
386
387fn wanted_face(inv: &Invocation<'_>) -> Result<&'static str> {
390 face_of(inv, &READ_FACES)
391}
392
393fn face_of(inv: &Invocation<'_>, faces: &[&'static str]) -> Result<&'static str> {
395 match optional(inv, "as")? {
396 None => Ok(faces[0]),
397 Some(asked) => {
398 let bare = asked.split(';').next().unwrap_or(asked).trim();
399 faces
400 .iter()
401 .find(|face| **face == bare)
402 .copied()
403 .ok_or_else(|| Error::InvalidArgument {
404 name: "as".to_string(),
405 detail: format!(
406 "`{asked}` is not a face this resource serves; one of {}",
407 faces.join(", ")
408 ),
409 })
410 }
411 }
412}
413
414fn plain(text: impl Into<String>) -> Representation {
415 Representation::new(
416 ReprType::new(PLAIN).with_param("charset", "utf-8"),
417 text.into().into_bytes(),
418 )
419}
420
421fn json<T: Serialize>(value: &T) -> Result<Representation> {
422 let mut bytes = serde_json::to_vec_pretty(value)
423 .map_err(|e| Error::Endpoint(format!("could not serialize a record: {e}")))?;
424 bytes.push(b'\n');
425 Ok(Representation::new(ReprType::new(JSON), bytes))
426}
427
428fn iri(text: &str) -> Result<Iri> {
429 Iri::parse(text).map_err(|e| Error::Endpoint(format!("`{text}` is not an IRI: {e}")))
430}
431
432fn inline(text: &str) -> ArgRef {
433 ArgRef::Inline(text.as_bytes().to_vec())
434}
435
436fn name_arg() -> ArgSpec {
437 ArgSpec::new("name")
438 .summary(
439 "The script's name: one segment of lowercase letters, digits, `-` and `_` \
440 (`eval`, `catalog`, `public`, `outcome` and `principal` are reserved).",
441 )
442 .class(XSD_STRING)
443 .binding()
444}
445
446fn as_arg() -> ArgSpec {
447 ArgSpec::new("as")
448 .summary(
449 "The face: `text/plain` (for people) or `application/json` (the versioned \
450 record, `\"schema\": 1`). Any other is refused, never substituted.",
451 )
452 .class(XSD_STRING)
453 .one_of(READ_FACES)
454 .default_value(PLAIN)
455 .optional()
456}
457
458fn record_as_arg() -> ArgSpec {
460 ArgSpec::new("as")
461 .summary(
462 "The face: `text/plain` (for people), `application/json` (the versioned record, \
463 `\"schema\": 1`) or `text/turtle` (the graph: PROV-O and the shared \
464 vocabulary, no blank nodes). Any other is refused, never substituted.",
465 )
466 .class(XSD_STRING)
467 .one_of(RECORD_FACES)
468 .default_value(PLAIN)
469 .optional()
470}
471
472fn turtle(graph: crate::graph::Graph) -> Result<Representation> {
473 let bytes = graph
474 .turtle()
475 .map_err(|e| Error::Endpoint(format!("could not write the graph face: {e}")))?;
476 Ok(Representation::new(
477 ReprType::new(TURTLE).with_param("charset", "utf-8"),
478 bytes,
479 ))
480}
481
482fn data_arg(arg: &str, summary: &str) -> ArgSpec {
483 ArgSpec::new(arg)
484 .summary(summary)
485 .class(XSD_STRING)
486 .optional()
487}
488
489#[derive(Serialize)]
495struct ScriptDocument<'a> {
496 iri: String,
497 #[serde(flatten)]
498 head: &'a Head,
499 #[serde(rename = "versionIri")]
500 version_iri: String,
501 language: Language,
502 requires: &'a BTreeSet<String>,
503 source: &'a str,
504}
505
506struct ScriptEndpoint {
507 shared: Arc<SpaceConfig>,
508}
509
510#[async_trait]
511impl Endpoint for ScriptEndpoint {
512 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
513 let name = name::from_bindings(inv)?;
514 let shared = &self.shared;
515 match inv.request.verb {
516 Verb::Exists => {
517 let (exists, seen) = match readable_head(inv, shared, &name)? {
518 None => (false, Sight::Everyone),
519 Some(head) => match sight(inv, shared, &head, &head.version) {
520 Sight::Hidden => (false, Sight::Hidden),
521 seen => (true, seen),
522 },
523 };
524 Ok(cached_if(
525 plain(if exists { "true\n" } else { "false\n" }),
526 seen,
527 ))
528 }
529 Verb::Source => {
530 let want = wanted_face(inv)?;
531 let (head, seen) =
532 seen_head(inv, shared, &name, readable_head(inv, shared, &name)?)?;
533 let version = head_version(shared, &head)?;
534 let repr = if want == JSON {
535 json(&ScriptDocument {
536 iri: name::script_iri(&name),
537 head: &head,
538 version_iri: name::version_iri(&name, &head.version),
539 language: version.language,
540 requires: &version.requires,
541 source: &version.source,
542 })?
543 } else {
544 plain(version.source)
545 };
546 Ok(cached_if(repr, seen))
547 }
548 Verb::Sink => publish(inv, shared, &name).await,
549 Verb::Delete => retire(inv, shared, &name),
550 other => Err(unsupported("script", other)),
551 }
552 }
553
554 fn name(&self) -> &str {
555 "script"
556 }
557
558 fn describe(&self) -> Description {
559 Description::new("script")
560 .title("A script")
561 .summary(
562 "A script as a resource: fetch its source without running it, publish or \
563 replace it (every write is a new content-addressed version), or retire it.",
564 )
565 .verb(Verb::Meta)
566 .action(
567 ActionSpec::new(Verb::Source)
568 .summary(
569 "The script, WITHOUT running it: its source (`text/plain`), or its head \
570 record (`application/json`): state, public flag, version, what it \
571 declares and what its publisher held. Needs \
572 `urn:cap:script:read:{name}`, or `urn:cap:script:read:public` for a \
573 public published script.",
574 )
575 .input(name_arg())
576 .input(as_arg())
577 .output(PLAIN)
578 .output(JSON)
579 .requires(CAP_READ),
580 )
581 .action(
582 ActionSpec::new(Verb::Exists)
583 .summary("Whether the script exists (to a caller who may read it).")
584 .input(name_arg())
585 .output(PLAIN)
586 .requires(CAP_READ),
587 )
588 .action(
589 ActionSpec::new(Verb::Sink)
590 .summary(
591 "Publish or replace the script; answers the new version's IRI. The \
592 declared `requires` (plus the language's own capability) must all \
593 be held by the publisher, or the publish is refused: a script never \
594 runs with more than its publisher held. Needs \
595 `urn:cap:script:write:{name}`.",
596 )
597 .input(name_arg())
598 .input(
599 ArgSpec::new("content")
600 .summary("The source text.")
601 .class(XSD_STRING),
602 )
603 .input(
604 ArgSpec::new("language")
605 .summary(
606 "The script's language: `lisp`, `sparql` (a query or update, \
607 when the host mounts a SPARQL door), or `plan` (an \
608 ik:Process graph, when the host binds the plan doors).",
609 )
610 .class(XSD_STRING)
611 .one_of(Language::ALL.map(Language::as_str))
612 .default_value("lisp")
613 .optional(),
614 )
615 .input(
616 ArgSpec::new("requires")
617 .summary(
618 "Whitespace-separated `urn:cap:` grants the script needs to \
619 run. Lisp: the language's capability is added; exact grants \
620 only, no wildcards, no exclusions. SPARQL: DERIVED from the \
621 graphs the text names; a plan: DERIVED from its steps' \
622 contracts. For both, omit it: one that says anything else \
623 is refused, naming the derived set.",
624 )
625 .class(XSD_STRING)
626 .optional(),
627 )
628 .input(
629 ArgSpec::new("public")
630 .summary(
631 "Whether holders of `urn:cap:script:run:public` (a host's \
632 anonymous principal) may run it and holders of \
633 `urn:cap:script:read:public` read it.",
634 )
635 .class(XSD_BOOLEAN)
636 .one_of(["true", "false"])
637 .default_value("false")
638 .optional(),
639 )
640 .input(
641 ArgSpec::new("state")
642 .summary(
643 "`published` (runnable) or `draft` (saved, not runnable, \
644 and visible only to its author until published). To \
645 retire, Delete.",
646 )
647 .class(XSD_STRING)
648 .one_of(["published", "draft"])
649 .default_value("published")
650 .optional(),
651 )
652 .input(
653 ArgSpec::new("if-version")
654 .summary(
655 "Write only if the head is still this version (`none`: only \
656 if the script does not exist yet); otherwise `Conflict`.",
657 )
658 .class(XSD_STRING)
659 .optional(),
660 )
661 .output(PLAIN)
662 .requires(CAP_WRITE),
663 )
664 .action(
665 ActionSpec::new(Verb::Delete)
666 .summary(
667 "Retire the script: it stays readable and its versions fetchable, but \
668 it never runs again until republished. Needs \
669 `urn:cap:script:delete:{name}`.",
670 )
671 .input(name_arg())
672 .output(PLAIN)
673 .requires(CAP_DELETE),
674 )
675 }
676}
677
678async fn publish(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Result<Representation> {
679 require(inv, &cap_write(name), "publishing a script")?;
680 let source = inv.inline_str("content")?;
681 let language = optional(inv, "language")?
682 .map(Language::parse)
683 .transpose()?
684 .unwrap_or(Language::Lisp);
685 let requires = match language {
686 Language::Lisp => {
687 let mut requires = optional(inv, "requires")?
688 .map(authority::parse_requires)
689 .transpose()?
690 .unwrap_or_default();
691 requires.insert(CAP_LISP.to_string());
692 requires
693 }
694 Language::Sparql => derived_requires(inv, shared, source)?,
695 Language::Plan => {
696 plan::check_bound(source)?;
700 plan::validate(inv, source).await?;
701 plan::analyze(source)?;
702 plan::derive(inv, source, optional(inv, "requires")?).await?
703 }
704 };
705 let public = optional(inv, "public")?
706 .map(|v| parse_bool("public", v))
707 .transpose()?
708 .unwrap_or(false);
709 let state = match optional(inv, "state")?.map(str::trim) {
710 None | Some("published") => State::Published,
711 Some("draft") => State::Draft,
712 Some(other) => {
713 return Err(Error::InvalidArgument {
714 name: "state".to_string(),
715 detail: format!(
716 "`{other}` is not `published` or `draft`; to retire a script, Delete it"
717 ),
718 })
719 }
720 };
721 let grant = authority::grant_at_publish(inv.capability, &requires)?;
724
725 let version = Version {
726 language,
727 requires,
728 source: source.to_string(),
729 };
730 let digest = version.digest();
731 let current = shared.backend.head(name)?;
732 if let Some(expected) = optional(inv, "if-version")? {
733 let at = current.as_ref().map_or("none", |h| h.version.as_str());
734 if at != expected.trim() {
735 return Err(Error::Conflict(format!(
736 "urn:script:{name} is at {at}, not {}; read it again and retry",
737 expected.trim()
738 )));
739 }
740 }
741 shared.backend.put_version(name, &version)?;
742
743 let principal = (shared.principal)(inv);
744 let at = inv.now().map(|t| t.as_millis());
745 let mut history = current
746 .as_ref()
747 .map(|h| h.history.clone())
748 .unwrap_or_default();
749 history.push(Event {
750 action: if state == State::Draft {
751 "draft"
752 } else {
753 "publish"
754 }
755 .to_string(),
756 version: digest.clone(),
757 state,
758 public,
759 principal: principal.clone(),
760 at,
761 });
762 let head = Head {
763 schema: SCHEMA,
764 name: name.to_string(),
765 version: digest.clone(),
766 state,
767 public,
768 granted: grant.granted,
769 exclusions: grant.exclusions,
770 publisher: principal,
771 updated: at,
772 history,
773 };
774 shared.backend.swap_head(name, current.as_ref(), &head)?;
775 shared.changed(name);
776 Ok(plain(format!("{}\n", name::version_iri(name, &digest))))
777}
778
779fn derived_requires(
783 inv: &Invocation<'_>,
784 shared: &SpaceConfig,
785 source: &str,
786) -> Result<BTreeSet<String>> {
787 let door = door(shared).map_err(|_| Error::InvalidArgument {
788 name: "language".to_string(),
789 detail: "this host takes no SPARQL scripts: it mounts no SPARQL door".to_string(),
790 })?;
791 let derived = sparql::analyze(source, door)?.requires();
792 if let Some(said) = optional(inv, "requires")? {
793 let said: BTreeSet<String> = said.split_whitespace().map(str::to_string).collect();
794 if said != derived {
795 return Err(Error::InvalidArgument {
796 name: "requires".to_string(),
797 detail: format!(
798 "a SPARQL script's authority is derived from the graphs its text names, \
799 not declared, and this one derives {}; the `requires` given says {}. \
800 Omit `requires`",
801 listed(&derived),
802 listed(&said)
803 ),
804 });
805 }
806 }
807 Ok(derived)
808}
809
810fn listed(scopes: &BTreeSet<String>) -> String {
811 if scopes.is_empty() {
812 "nothing".to_string()
813 } else {
814 scopes.iter().cloned().collect::<Vec<_>>().join(" ")
815 }
816}
817
818fn door(shared: &SpaceConfig) -> Result<&SparqlDoor> {
819 shared.sparql.as_ref().ok_or_else(|| {
820 Error::Endpoint(
821 "this host mounts no SPARQL door (`SpaceConfig::sparql`), so it cannot run a \
822 SPARQL script"
823 .to_string(),
824 )
825 })
826}
827
828fn retire(inv: &Invocation<'_>, shared: &SpaceConfig, name: &str) -> Result<Representation> {
829 require(inv, &cap_delete(name), "retiring a script")?;
830 let current = found(shared.backend.head(name)?, name)?;
831 let iri = name::script_iri(name);
832 if current.state == State::Retired {
833 return Ok(plain(format!("{iri} was already retired\n")));
834 }
835 let at = inv.now().map(|t| t.as_millis());
836 let mut head = current.clone();
837 head.state = State::Retired;
838 head.updated = at;
839 head.history.push(Event {
840 action: "retire".to_string(),
841 version: current.version.clone(),
842 state: State::Retired,
843 public: current.public,
844 principal: (shared.principal)(inv),
845 at,
846 });
847 shared.backend.swap_head(name, Some(¤t), &head)?;
848 shared.changed(name);
849 Ok(plain(format!("retired {iri}\n")))
850}
851
852struct VersionEndpoint {
857 shared: Arc<SpaceConfig>,
858}
859
860#[async_trait]
861impl Endpoint for VersionEndpoint {
862 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
863 let name = name::from_bindings(inv)?;
864 let digest = inv
865 .bindings
866 .get("digest")
867 .ok_or_else(|| Error::Endpoint("no `digest` captured".to_string()))?
868 .to_string();
869 let head = readable_head(inv, &self.shared, &name)?;
870 let version = self.shared.backend.version(&name, &digest);
871 let seen = match (&version, &head) {
875 (Ok(Some(_)), Some(head)) => sight(inv, &self.shared, head, &digest),
876 (Ok(Some(_)), None) if !inv.capability.is_root() => Sight::Hidden,
877 _ => Sight::Everyone,
878 };
879 let no_such = || Error::NotFound(format!("urn:script:{name} has no version {digest}"));
880 let script_thread = name::script_iri(&name);
890 match inv.request.verb {
891 Verb::Exists => {
892 let exists = match version {
893 Ok(v) => v.is_some() && seen != Sight::Hidden,
894 Err(Error::InvalidArgument { .. }) => false,
895 Err(other) => return Err(other),
896 };
897 Ok(
898 cached_if(plain(if exists { "true\n" } else { "false\n" }), seen)
899 .depends_on(script_thread),
900 )
901 }
902 Verb::Source => {
903 let want = wanted_face(inv)?;
904 let version = version?.ok_or_else(no_such)?;
905 if seen == Sight::Hidden {
906 return Err(no_such());
907 }
908 let repr = if want == JSON {
909 json(&version.document(&name))?
910 } else {
911 plain(version.source)
912 };
913 Ok(cached_if(repr, seen).depends_on(script_thread))
914 }
915 other => Err(unsupported("script-version", other)),
916 }
917 }
918
919 fn name(&self) -> &str {
920 "script-version"
921 }
922
923 fn describe(&self) -> Description {
924 let digest = || {
925 ArgSpec::new("digest")
926 .summary("The version's digest: `sha256:` and 64 hex digits over its content.")
927 .class(XSD_STRING)
928 .binding()
929 };
930 Description::new("script-version")
931 .title("One version of a script")
932 .summary(
933 "An immutable version, named by the sha256 of its content (language, declared \
934 capability, source). Republishing moves the head; every version stays \
935 fetchable here.",
936 )
937 .verb(Verb::Meta)
938 .action(
939 ActionSpec::new(Verb::Source)
940 .summary(
941 "The version's source (`text/plain`) or its record (`application/json`).",
942 )
943 .input(name_arg())
944 .input(digest())
945 .input(as_arg())
946 .output(PLAIN)
947 .output(JSON)
948 .requires(CAP_READ),
949 )
950 .action(
951 ActionSpec::new(Verb::Exists)
952 .summary("Whether the script has this version.")
953 .input(name_arg())
954 .input(digest())
955 .output(PLAIN)
956 .requires(CAP_READ),
957 )
958 }
959}
960
961#[derive(Clone, Debug, Serialize, Deserialize)]
971pub struct Prepared {
972 pub schema: u32,
974 pub name: String,
976 pub iri: String,
978 pub version: String,
980 pub language: Language,
982 pub evaluator: String,
984 pub program: String,
986 pub requires: BTreeSet<String>,
988 pub granted: BTreeSet<String>,
990 pub exclusions: BTreeSet<String>,
992 pub public: bool,
994 pub state: State,
996 #[serde(default, skip_serializing_if = "Option::is_none")]
998 pub sparql: Option<Analysis>,
999 #[serde(default, skip_serializing_if = "Option::is_none")]
1001 pub plan: Option<plan::Analysis>,
1002}
1003
1004struct CompiledEndpoint {
1005 shared: Arc<SpaceConfig>,
1006}
1007
1008#[async_trait]
1009impl Endpoint for CompiledEndpoint {
1010 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1011 if inv.request.verb != Verb::Source {
1012 return Err(unsupported("script-compiled", inv.request.verb));
1013 }
1014 let name = name::from_bindings(inv)?;
1015 let gated = gated_head(
1018 inv,
1019 &self.shared,
1020 &name,
1021 &[cap_read(&name), cap_run(&name)],
1022 &[CAP_READ_PUBLIC, CAP_RUN_PUBLIC],
1023 )?;
1024 let (head, seen) = seen_head(inv, &self.shared, &name, gated)?;
1025 let version = head_version(&self.shared, &head)?;
1026 let (evaluator, analysis, plan_analysis) = match version.language {
1027 Language::Lisp => (LISP_EVAL.to_string(), None, None),
1028 Language::Sparql => {
1029 let door = door(&self.shared)?;
1030 let analysis = sparql::analyze(&version.source, door)?;
1031 (door.query_iri(analysis.form), Some(analysis), None)
1032 }
1033 Language::Plan => (
1034 plan::EVAL.to_string(),
1035 None,
1036 Some(plan::analyze(&version.source)?),
1037 ),
1038 };
1039 let prepared = Prepared {
1040 schema: SCHEMA,
1041 iri: name::part_iri(&name, "compiled"),
1042 version: head.version.clone(),
1043 language: version.language,
1044 evaluator,
1045 program: version.source,
1046 requires: version.requires,
1047 granted: head.granted,
1048 exclusions: head.exclusions,
1049 public: head.public,
1050 state: head.state,
1051 sparql: analysis,
1052 plan: plan_analysis,
1053 name: name.clone(),
1054 };
1055 Ok(cached_if(json(&prepared)?, seen).depends_on(name::script_iri(&name)))
1059 }
1060
1061 fn name(&self) -> &str {
1062 "script-compiled"
1063 }
1064
1065 fn describe(&self) -> Description {
1066 Description::new("script-compiled")
1067 .title("A script's compiled form")
1068 .summary(
1069 "The head version prepared for its evaluator, with the authority it runs \
1070 under (and, for SPARQL, its form, parameters and graphs; for a plan, its \
1071 steps' verbs and its parameters): cached, and cut \
1072 whenever the script is republished or retired. Readable by a holder of the \
1073 script's read OR run grant (a run reads its program here).",
1074 )
1075 .verb(Verb::Meta)
1076 .action(
1077 ActionSpec::new(Verb::Source)
1078 .summary("The prepared form, as JSON.")
1079 .input(name_arg())
1080 .output(JSON)
1081 .requires(CAP_ANY),
1082 )
1083 }
1084}
1085
1086const LISP_EVAL: &str = "urn:lisp:eval";
1091
1092async fn prepare_run(
1095 inv: &Invocation<'_>,
1096 shared: &SpaceConfig,
1097 name: &str,
1098) -> Result<(Prepared, BTreeSet<String>, Ceiling)> {
1099 let compiled = inv
1102 .issue(Request::new(
1103 Verb::Source,
1104 iri(&name::part_iri(name, "compiled"))?,
1105 ))
1106 .await?;
1107 let prepared: Prepared = serde_json::from_slice(&compiled.bytes).map_err(|e| {
1108 Error::Endpoint(format!(
1109 "urn:script:{name}:compiled did not answer a prepared form: {e}"
1110 ))
1111 })?;
1112 if prepared.name != name {
1113 return Err(Error::Endpoint(format!(
1114 "urn:script:{name}:compiled answered for `{}`",
1115 prepared.name
1116 )));
1117 }
1118 let public = prepared.public && prepared.state == State::Published;
1119 let may_run = holds(inv, &cap_run(name)) || (public && holds(inv, CAP_RUN_PUBLIC));
1120 if !may_run {
1121 return Err(Error::Denied(format!(
1122 "running urn:script:{name} needs `{}`{}",
1123 cap_run(name),
1124 if prepared.public {
1125 format!(" (or `{CAP_RUN_PUBLIC}`, since it is public)")
1126 } else {
1127 " — it is not public".to_string()
1128 }
1129 )));
1130 }
1131 match prepared.state {
1132 State::Published => {}
1133 State::Draft => {
1134 return Err(Error::Conflict(format!(
1135 "urn:script:{name} is a draft; publish it (`state=published`) to run it, or \
1136 try the code at urn:script:eval"
1137 )))
1138 }
1139 State::Retired => {
1140 return Err(Error::Conflict(format!(
1141 "urn:script:{name} is retired; publish it again to run it"
1142 )))
1143 }
1144 }
1145 let ceiling = (shared.ceiling)(name);
1146 let keep = authority::effective(
1147 &prepared.requires,
1148 &prepared.granted,
1149 &prepared.exclusions,
1150 &ceiling,
1151 );
1152 Ok((prepared, keep, ceiling))
1153}
1154
1155fn lisp_request(program: &str, data: Option<&str>) -> Result<Request> {
1157 let mut request = Request::new(Verb::Source, iri(LISP_EVAL)?).with_arg("in", inline(program));
1158 if let Some(data) = data {
1159 request = request.with_arg("data", inline(data));
1160 }
1161 Ok(request)
1162}
1163
1164const DATA_SUMMARY: &str = "Optional data the script reads with `(input)` — data, never code.";
1166
1167#[derive(Clone, Copy, PartialEq, Eq)]
1169enum Door {
1170 Result,
1172 Runs,
1174}
1175
1176struct Planned {
1179 request: Request,
1180 keep: BTreeSet<String>,
1181}
1182
1183async fn prepare_request(
1185 inv: &Invocation<'_>,
1186 shared: &SpaceConfig,
1187 prepared: &Prepared,
1188 keep: BTreeSet<String>,
1189 ceiling: &Ceiling,
1190 through: Door,
1191) -> Result<Planned> {
1192 match prepared.language {
1193 Language::Lisp => {
1194 let data = optional(
1195 inv,
1196 match through {
1197 Door::Result => "data",
1198 Door::Runs => "content",
1199 },
1200 )?;
1201 Ok(Planned {
1202 request: lisp_request(&prepared.program, data)?,
1203 keep,
1204 })
1205 }
1206 Language::Sparql => plan_sparql(inv, shared, prepared, keep, ceiling, through).await,
1207 Language::Plan => plan_eval_request(inv, prepared, keep, ceiling, through),
1208 }
1209}
1210
1211fn plan_eval_request(
1218 inv: &Invocation<'_>,
1219 prepared: &Prepared,
1220 keep: BTreeSet<String>,
1221 ceiling: &Ceiling,
1222 through: Door,
1223) -> Result<Planned> {
1224 let name = &prepared.name;
1225 let analysis = prepared.plan.as_ref().ok_or_else(|| {
1226 Error::Endpoint(format!(
1227 "urn:script:{name}:compiled carries no plan analysis"
1228 ))
1229 })?;
1230 if through == Door::Result && analysis.mutates() {
1231 let writers: Vec<String> = analysis
1232 .steps
1233 .iter()
1234 .filter(|s| s.mutates())
1235 .map(|s| format!("<{}> ({} <{}>)", s.iri, s.verb, s.resolves))
1236 .collect();
1237 return Err(Error::InvalidArgument {
1238 name: "name".to_string(),
1239 detail: format!(
1240 "urn:script:{name} is a plan that writes ({}), so it is not a read: run it with \
1241 a Sink to urn:script:{name}:runs",
1242 writers.join(", ")
1243 ),
1244 });
1245 }
1246 let declared: Vec<(&str, bool)> = analysis
1247 .parameters
1248 .iter()
1249 .map(|p| (p.name.as_str(), p.must_be_given()))
1250 .collect();
1251 let values = given_parameters(inv, name, &declared, through)?;
1252 let face = optional(inv, "as")?;
1253 Ok(Planned {
1254 request: plan::eval_request(&prepared.program, &values, face),
1255 keep: plan::expand_families(keep, inv.capability, ceiling),
1256 })
1257}
1258
1259async fn plan_sparql(
1269 inv: &Invocation<'_>,
1270 shared: &SpaceConfig,
1271 prepared: &Prepared,
1272 mut keep: BTreeSet<String>,
1273 ceiling: &Ceiling,
1274 through: Door,
1275) -> Result<Planned> {
1276 let name = &prepared.name;
1277 let door = door(shared)?;
1278 let analysis = prepared.sparql.as_ref().ok_or_else(|| {
1279 Error::Endpoint(format!(
1280 "urn:script:{name}:compiled carries no SPARQL analysis"
1281 ))
1282 })?;
1283 if through == Door::Result && !analysis.form.is_read() {
1284 return Err(Error::InvalidArgument {
1285 name: "name".to_string(),
1286 detail: format!(
1287 "urn:script:{name} is a SPARQL UPDATE, which is a write: run it with a Sink to \
1288 urn:script:{name}:runs"
1289 ),
1290 });
1291 }
1292 let values = parameter_values(inv, name, &analysis.parameters, through)?;
1293 let face = sparql_face(inv, analysis.form)?;
1294 let bound = sparql::bind(&prepared.program, door, &values)?;
1295
1296 let runner = inv.capability;
1297 let root_marker = keep.remove(CAP_READ_GRAPH);
1300 let family = prepared.requires.contains(CAP_READ_GRAPH);
1301 let by_family = |scope: &str, keep: &BTreeSet<String>| {
1302 family
1303 && runner.allows(scope)
1304 && if root_marker {
1305 ceiling.allows(scope)
1306 } else {
1307 keep.contains(scope)
1308 }
1309 };
1310
1311 if bound.form == Form::Update {
1312 let graph = bound.graphs.iter().next().cloned().ok_or_else(|| {
1315 Error::Endpoint(format!("urn:script:{name} names no graph to update"))
1316 })?;
1317 for scope in &prepared.requires {
1318 may_use(name, &graph, scope, &keep, runner)?;
1319 }
1320 let request = Request::new(Verb::Sink, iri(&door.query_iri(Form::Update))?)
1321 .with_arg("content", inline(&bound.text))
1322 .with_arg("graph", inline(&graph));
1323 return Ok(Planned { request, keep });
1324 }
1325
1326 let mut extra = BTreeSet::new();
1327 for graph in &bound.graphs {
1328 let scope = sparql::cap_read_graph(graph);
1329 if !keep.contains(&scope) && by_family(&scope, &keep) {
1330 extra.insert(scope);
1331 }
1332 }
1333 let mut dataset = bound.graphs.clone();
1334 if bound.any_graph {
1335 if !family {
1336 return Err(Error::Endpoint(format!(
1337 "urn:script:{name} reads a variable graph but its record derives no \
1338 `{CAP_READ_GRAPH}`; republish it"
1339 )));
1340 }
1341 let under = |scopes: &mut dyn Iterator<Item = &String>| -> BTreeSet<String> {
1342 scopes
1343 .filter_map(|s| s.strip_prefix(sparql::READ_GRAPH_PREFIX))
1344 .filter(|g| !g.starts_with('-') && !g.ends_with('*'))
1345 .map(str::to_string)
1346 .collect()
1347 };
1348 let candidates = if !root_marker {
1349 under(&mut keep.iter())
1350 } else if let Some(held) = runner.scopes() {
1351 under(&mut held.iter())
1352 } else {
1353 let listing = inv
1356 .issue_attenuated(
1357 Request::new(Verb::Source, iri(&door.graphs_iri())?),
1358 [sparql::CAP_STORE_READ.to_string()],
1359 )
1360 .await?;
1361 String::from_utf8_lossy(&listing.bytes)
1362 .lines()
1363 .map(str::trim)
1364 .filter(|l| !l.is_empty())
1365 .map(str::to_string)
1366 .collect()
1367 };
1368 for graph in candidates {
1369 let scope = sparql::cap_read_graph(&graph);
1370 if keep.contains(&scope) && runner.allows(&scope) || by_family(&scope, &keep) {
1371 extra.insert(scope);
1372 dataset.insert(graph);
1373 }
1374 }
1375 }
1376 if dataset.is_empty() {
1377 return Err(Error::Denied(format!(
1378 "urn:script:{name} reads the caller's readable graphs, and this run may read none: \
1379 it needs `{}` grants the publisher held, the host allows and the runner holds",
1380 sparql::READ_GRAPH_PREFIX
1381 )));
1382 }
1383 keep.extend(extra);
1384 for graph in &dataset {
1385 may_use(name, graph, &sparql::cap_read_graph(graph), &keep, runner)?;
1386 }
1387 let graphs = dataset.into_iter().collect::<Vec<_>>().join(" ");
1388 let request = Request::new(Verb::Source, iri(&door.query_iri(bound.form))?)
1389 .with_arg("query", inline(&bound.text))
1390 .with_arg("graph", inline(&graphs))
1391 .with_arg("as", inline(face));
1392 Ok(Planned { request, keep })
1393}
1394
1395fn may_use(
1399 name: &str,
1400 graph: &str,
1401 scope: &str,
1402 keep: &BTreeSet<String>,
1403 runner: &ikigai_core::Capability,
1404) -> Result<()> {
1405 if !runner.allows(scope) {
1406 return Err(Error::Denied(format!(
1407 "running urn:script:{name} reads or writes <{graph}>, which needs `{scope}`, and the \
1408 runner does not hold it"
1409 )));
1410 }
1411 if !keep.contains(scope) {
1412 return Err(Error::Denied(format!(
1413 "running urn:script:{name} reads or writes <{graph}>, which needs `{scope}`, and \
1414 this script may not have it: its publisher did not hold it, or the host's ceiling \
1415 for it does not allow it"
1416 )));
1417 }
1418 Ok(())
1419}
1420
1421fn sparql_face(inv: &Invocation<'_>, form: Form) -> Result<&'static str> {
1424 let faces = form.faces();
1425 match optional(inv, "as")? {
1426 None => Ok(faces[0]),
1427 Some(asked) => {
1428 let bare = asked.split(';').next().unwrap_or(asked).trim();
1429 faces
1430 .iter()
1431 .find(|f| **f == bare)
1432 .copied()
1433 .ok_or_else(|| Error::InvalidArgument {
1434 name: "as".to_string(),
1435 detail: format!(
1436 "`{asked}` is not a face a SPARQL {} answers in; one of {}",
1437 form.as_str().to_uppercase(),
1438 faces.join(", ")
1439 ),
1440 })
1441 }
1442 }
1443}
1444
1445fn parameter_values(
1450 inv: &Invocation<'_>,
1451 name: &str,
1452 parameters: &[Parameter],
1453 through: Door,
1454) -> Result<BTreeMap<String, Value>> {
1455 let declared: Vec<(&str, bool)> = parameters
1456 .iter()
1457 .map(|p| (p.name.as_str(), p.required && p.default.is_none()))
1458 .collect();
1459 let mut given = given_parameters(inv, name, &declared, through)?;
1460 let mut values = BTreeMap::new();
1461 for parameter in parameters {
1462 if let Some(lexical) = given
1463 .remove(¶meter.name)
1464 .or_else(|| parameter.default.clone())
1465 {
1466 values.insert(parameter.name.clone(), parameter.value(&lexical)?);
1467 }
1468 }
1469 Ok(values)
1470}
1471
1472fn given_parameters(
1481 inv: &Invocation<'_>,
1482 name: &str,
1483 declared: &[(&str, bool)],
1484 through: Door,
1485) -> Result<BTreeMap<String, String>> {
1486 let listed = || {
1487 if declared.is_empty() {
1488 "it declares none".to_string()
1489 } else {
1490 format!(
1491 "its parameters are {}",
1492 declared
1493 .iter()
1494 .map(|(p, _)| *p)
1495 .collect::<Vec<_>>()
1496 .join(", ")
1497 )
1498 }
1499 };
1500 for arg in inv.request.args.keys() {
1501 let known = arg == "as"
1502 || (through == Door::Runs && arg == "content")
1503 || declared.iter().any(|(p, _)| p == arg);
1504 if !known {
1505 return Err(Error::InvalidArgument {
1506 name: arg.clone(),
1507 detail: format!("is not a parameter of urn:script:{name}; {}", listed()),
1508 });
1509 }
1510 }
1511 let mut piped: BTreeMap<String, String> = BTreeMap::new();
1512 if through == Door::Runs {
1513 if let Some(content) = optional(inv, "content")?.filter(|c| !c.trim().is_empty()) {
1515 let object: serde_json::Map<String, serde_json::Value> = serde_json::from_str(content)
1516 .map_err(|e| Error::InvalidArgument {
1517 name: "content".to_string(),
1518 detail: format!(
1519 "a script's piped content is its parameters as one JSON object ({e})"
1520 ),
1521 })?;
1522 for (key, value) in object {
1523 let lexical = match value {
1524 serde_json::Value::String(s) => s,
1525 serde_json::Value::Number(n) => n.to_string(),
1526 serde_json::Value::Bool(b) => b.to_string(),
1527 other => {
1528 return Err(Error::InvalidArgument {
1529 name: "content".to_string(),
1530 detail: format!(
1531 "`{key}` is {other}; a parameter's value is a string, a number \
1532 or a boolean"
1533 ),
1534 })
1535 }
1536 };
1537 piped.insert(key, lexical);
1538 }
1539 }
1540 }
1541 let mut given = BTreeMap::new();
1542 for (parameter, must) in declared {
1543 let named = optional(inv, parameter)?;
1544 let lexical = match (named, piped.remove(*parameter)) {
1545 (Some(_), Some(_)) => {
1546 return Err(Error::InvalidArgument {
1547 name: parameter.to_string(),
1548 detail: "is given both by name and in the piped content".to_string(),
1549 })
1550 }
1551 (Some(value), None) => Some(value.to_string()),
1552 (None, Some(value)) => Some(value),
1553 (None, None) => None,
1554 };
1555 match lexical {
1556 Some(lexical) => {
1557 given.insert(parameter.to_string(), lexical);
1558 }
1559 None if *must => return Err(Error::MissingArgument(parameter.to_string())),
1560 None => {}
1561 }
1562 }
1563 if let Some(stray) = piped.keys().next() {
1564 return Err(Error::InvalidArgument {
1565 name: "content".to_string(),
1566 detail: format!(
1567 "`{stray}` is not a parameter of urn:script:{name}; {}",
1568 listed()
1569 ),
1570 });
1571 }
1572 Ok(given)
1573}
1574
1575struct ResultEndpoint {
1576 shared: Arc<SpaceConfig>,
1577}
1578
1579#[async_trait]
1580impl Endpoint for ResultEndpoint {
1581 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1582 if inv.request.verb != Verb::Source {
1583 return Err(unsupported("script-result", inv.request.verb));
1584 }
1585 let name = name::from_bindings(inv)?;
1586 let (prepared, keep, ceiling) = prepare_run(inv, &self.shared, &name).await?;
1587 let planned =
1588 prepare_request(inv, &self.shared, &prepared, keep, &ceiling, Door::Result).await?;
1589 let answer = inv.issue_attenuated(planned.request, planned.keep).await?;
1592 Ok(Representation::new(answer.repr_type, answer.bytes)
1598 .cacheable()
1599 .depends_on(name::authority_thread(&name)))
1600 }
1601
1602 fn name(&self) -> &str {
1603 "script-result"
1604 }
1605
1606 fn describe(&self) -> Description {
1607 Description::new("script-result")
1608 .title("A script's result")
1609 .summary(
1610 "Run the script as a READ and answer its value. It runs under the runner's \
1611 capability narrowed to what the script declares, its publisher held and \
1612 the host allows it — never more. Cacheable exactly as far as the script's \
1613 own sub-requests are (a Lisp program opts in with `(cacheable …)`), and \
1614 recomputed when the script is republished. No run record: a cached answer \
1615 runs nothing; record a run with `…:runs`. A published SPARQL script is \
1616 its own entry, with its parameters as arguments.",
1617 )
1618 .verb(Verb::Meta)
1619 .action(
1620 ActionSpec::new(Verb::Source)
1621 .summary("The script's answer.")
1622 .input(name_arg())
1623 .input(data_arg("data", DATA_SUMMARY))
1624 .output(PLAIN)
1625 .requires(CAP_RUN)
1626 .requires(CAP_LISP),
1627 )
1628 }
1629}
1630
1631struct RunsEndpoint {
1632 shared: Arc<SpaceConfig>,
1633}
1634
1635#[async_trait]
1636impl Endpoint for RunsEndpoint {
1637 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1638 if inv.request.verb != Verb::Sink {
1639 return Err(unsupported("script-runs", inv.request.verb));
1640 }
1641 let name = name::from_bindings(inv)?;
1642 let shared = &self.shared;
1643 let (prepared, keep, ceiling) = prepare_run(inv, shared, &name).await?;
1644 let planned = prepare_request(inv, shared, &prepared, keep, &ceiling, Door::Runs).await?;
1647 let runs_as = inv.capability.attenuate(planned.keep.iter().cloned());
1648 let mut run = Run {
1649 schema: SCHEMA,
1650 name: name.clone(),
1651 id: 0,
1652 version: prepared.version.clone(),
1653 principal: (shared.principal)(inv),
1654 capability: runs_as.scopes().cloned().unwrap_or_default(),
1655 started: inv.now().map(|t| t.as_millis()),
1656 ended: None,
1657 outcome: Outcome::Running,
1658 result: None,
1659 truncated: false,
1660 trace_span: inv.trace_span(),
1661 };
1662 run.id = shared.backend.start_run(&name, &run)?;
1663 let answer = inv.issue_attenuated(planned.request, planned.keep).await;
1664 run.ended = inv.now().map(|t| t.as_millis());
1665 match answer {
1666 Ok(repr) => {
1667 let (text, truncated) = model::truncate(
1668 String::from_utf8_lossy(&repr.bytes).into_owned(),
1669 MAX_RECORDED_RESULT,
1670 );
1671 run.outcome = Outcome::Ok;
1672 run.result = Some(text);
1673 run.truncated = truncated;
1674 shared.backend.finish_run(&name, &run)?;
1675 Ok(plain(format!("{}\n", run.iri())))
1676 }
1677 Err(error) => {
1678 run.outcome = Outcome::Failed {
1679 kind: kind(&error).to_string(),
1680 message: error.to_string(),
1681 };
1682 shared.backend.finish_run(&name, &run)?;
1683 Err(annotate(error, &run.iri()))
1684 }
1685 }
1686 }
1687
1688 fn name(&self) -> &str {
1689 "script-runs"
1690 }
1691
1692 fn describe(&self) -> Description {
1693 Description::new("script-runs")
1694 .title("A script's runs")
1695 .summary(
1696 "Run the script for its EFFECTS and record the run: who (as the host \
1697 stamped it), which version, under exactly what capability, when, and the \
1698 outcome. Answers the run's IRI; a run that fails is recorded too, and its \
1699 error names the record. A published SPARQL script is its own entry, with \
1700 its parameters as arguments.",
1701 )
1702 .verb(Verb::Meta)
1703 .action(
1704 ActionSpec::new(Verb::Sink)
1705 .summary("Run the script and record the run.")
1706 .input(name_arg())
1707 .input(data_arg("content", DATA_SUMMARY))
1708 .output(PLAIN)
1709 .requires(CAP_RUN)
1710 .requires(CAP_LISP),
1711 )
1712 }
1713}
1714
1715#[derive(Clone)]
1722struct PerScript {
1723 result: Arc<dyn Endpoint>,
1724 runs: Arc<dyn Endpoint>,
1725 reads: bool,
1727 listed: bool,
1732}
1733
1734struct Described {
1746 inner: Arc<dyn Endpoint>,
1747 id: String,
1748 description: Description,
1749}
1750
1751#[async_trait]
1752impl Endpoint for Described {
1753 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
1754 self.inner.invoke(inv).await
1755 }
1756
1757 fn name(&self) -> &str {
1758 &self.id
1759 }
1760
1761 fn describe(&self) -> Description {
1762 self.description.clone()
1763 }
1764}
1765
1766pub struct ScriptSpace {
1769 inner: EndpointSpace,
1770 shared: Arc<SpaceConfig>,
1771}
1772
1773impl ScriptSpace {
1774 fn per_script(&self, name: &str) -> Option<PerScript> {
1779 let shared = &self.shared;
1780 if let Some(known) = shared
1781 .described
1782 .lock()
1783 .expect("the described-script memo")
1784 .get(name)
1785 {
1786 return known.clone();
1787 }
1788 let built = self.build(name);
1789 if built.is_some() || matches!(shared.backend.head(name), Ok(Some(_))) {
1792 shared
1793 .described
1794 .lock()
1795 .expect("the described-script memo")
1796 .insert(name.to_string(), built.clone());
1797 }
1798 built
1799 }
1800
1801 fn build(&self, name: &str) -> Option<PerScript> {
1802 let shared = &self.shared;
1803 let head = shared.backend.head(name).ok()??;
1804 if !ever_published(&head, &head.version) {
1811 return None;
1812 }
1813 let version = shared.backend.version(name, &head.version).ok()??;
1814 match version.language {
1815 Language::Lisp => None,
1816 Language::Sparql => self.build_sparql(name, &head, &version),
1817 Language::Plan => self.build_plan(name, &head, &version),
1818 }
1819 }
1820
1821 fn described(
1823 &self,
1824 inner: Arc<dyn Endpoint>,
1825 id: String,
1826 description: Description,
1827 ) -> Arc<dyn Endpoint> {
1828 Arc::new(Described {
1829 inner,
1830 id,
1831 description,
1832 }) as Arc<dyn Endpoint>
1833 }
1834
1835 fn result_endpoint(&self) -> Arc<dyn Endpoint> {
1836 Arc::new(ResultEndpoint {
1837 shared: Arc::clone(&self.shared),
1838 })
1839 }
1840
1841 fn runs_endpoint(&self) -> Arc<dyn Endpoint> {
1842 Arc::new(RunsEndpoint {
1843 shared: Arc::clone(&self.shared),
1844 })
1845 }
1846
1847 fn build_sparql(&self, name: &str, head: &Head, version: &Version) -> Option<PerScript> {
1848 let door = self.shared.sparql.as_ref()?;
1849 let analysis = sparql::analyze(&version.source, door).ok()?;
1850 let about = about(&version.source);
1851 let inputs: Vec<ArgSpec> = analysis
1852 .parameters
1853 .iter()
1854 .map(|p| {
1855 let mut arg = ArgSpec::new(p.name.clone())
1856 .summary(
1857 p.summary
1858 .clone()
1859 .unwrap_or_else(|| format!("The value of ?{} in the query.", p.name)),
1860 )
1861 .class(p.kind.iri().to_string());
1862 if let Some(default) = &p.default {
1863 arg = arg.default_value(default.clone()).optional();
1864 } else if !p.required {
1865 arg = arg.optional();
1866 }
1867 arg
1868 })
1869 .collect();
1870 let params = |spec: ActionSpec| contracted(spec, &inputs, name, head, version);
1871 let form = analysis.form;
1872 let faces = form.faces();
1873 let as_arg = || {
1874 ArgSpec::new("as")
1875 .summary(format!(
1876 "The face: one of {}. Any other is refused, never substituted.",
1877 faces.join(", ")
1878 ))
1879 .class(XSD_STRING)
1880 .one_of(faces.iter().copied())
1881 .default_value(faces[0])
1882 .optional()
1883 };
1884 let id = format!("script-{name}-result");
1885 let description = if form.is_read() {
1886 let mut spec = params(ActionSpec::new(Verb::Source))
1887 .summary(format!("Run the query {name} and answer its result."))
1888 .input(as_arg());
1889 for face in faces {
1890 spec = spec.output(*face);
1891 }
1892 Description::new(id.clone())
1893 .title(format!("{name}: a SPARQL {}", form.as_str().to_uppercase()))
1894 .summary(format!(
1895 "{about}Runs as a READ under the runner's capability narrowed to the \
1896 graphs the query names; cached as the store's answer is, and \
1897 recomputed after a write to the store or a republish."
1898 ))
1899 .verb(Verb::Meta)
1900 .action(spec)
1901 } else {
1902 Description::new(id.clone())
1905 .title(format!("{name}: a SPARQL UPDATE (run it at …:runs)"))
1906 .summary(format!(
1907 "{about}A write: run it with a Sink to urn:script:{name}:runs."
1908 ))
1909 .verb(Verb::Meta)
1910 };
1911 let result = self.described(self.result_endpoint(), id, description);
1912 let id = format!("script-{name}-runs");
1913 let mut spec = params(ActionSpec::new(Verb::Sink))
1914 .summary(format!(
1915 "Run {name} for its effects and record the run; answers the run's IRI."
1916 ))
1917 .input(piped_parameters_arg())
1918 .output(PLAIN);
1919 if form.is_read() {
1920 spec = spec.input(as_arg());
1921 }
1922 let runs = self.described(
1923 self.runs_endpoint(),
1924 id.clone(),
1925 Description::new(id)
1926 .title(format!("{name}: runs"))
1927 .summary(format!(
1928 "{about}Each run is recorded: who, which version, under exactly what \
1929 capability, when, and the outcome."
1930 ))
1931 .verb(Verb::Meta)
1932 .action(spec),
1933 );
1934 Some(PerScript {
1935 result,
1936 runs,
1937 reads: form.is_read(),
1938 listed: head.state == State::Published,
1939 })
1940 }
1941
1942 fn build_plan(&self, name: &str, head: &Head, version: &Version) -> Option<PerScript> {
1945 let analysis = plan::analyze(&version.source).ok()?;
1946 let about = about(&version.source);
1947 let inputs: Vec<ArgSpec> = analysis
1948 .parameters
1949 .iter()
1950 .map(|p| {
1951 let mut arg = ArgSpec::new(p.name.clone())
1952 .summary(
1953 p.summary
1954 .clone()
1955 .unwrap_or_else(|| format!("The plan's parameter `{}`.", p.name)),
1956 )
1957 .class(p.class.clone().unwrap_or_else(|| XSD_STRING.to_string()));
1958 if let Some(default) = &p.default {
1959 arg = arg.default_value(default.clone()).optional();
1960 } else if !p.required {
1961 arg = arg.optional();
1962 }
1963 arg
1964 })
1965 .collect();
1966 let params = |spec: ActionSpec| contracted(spec, &inputs, name, head, version);
1967 let as_arg = || {
1971 ArgSpec::new("as")
1972 .summary(
1973 "The face of the plan's result: when its result step served another \
1974 media type it is transrepted, and refused when nothing converts it.",
1975 )
1976 .class(XSD_STRING)
1977 .optional()
1978 };
1979 let reads = !analysis.mutates();
1980 let id = format!("script-{name}-result");
1981 let description = if reads {
1982 Description::new(id.clone())
1983 .title(format!("{name}: a plan"))
1984 .summary(format!(
1985 "{about}Runs as a READ (every step reads) under the runner's capability \
1986 narrowed to what the plan's steps require; as cacheable as its least \
1987 cacheable step, and recomputed after a republish."
1988 ))
1989 .verb(Verb::Meta)
1990 .action(
1991 params(ActionSpec::new(Verb::Source))
1992 .summary(format!("Run the plan {name} and answer its result."))
1993 .input(as_arg())
1994 .output(PLAIN),
1995 )
1996 } else {
1997 Description::new(id.clone())
2000 .title(format!("{name}: a plan that writes (run it at …:runs)"))
2001 .summary(format!(
2002 "{about}A write: run it with a Sink to urn:script:{name}:runs."
2003 ))
2004 .verb(Verb::Meta)
2005 };
2006 let result = self.described(self.result_endpoint(), id, description);
2007 let id = format!("script-{name}-runs");
2008 let runs = self.described(
2009 self.runs_endpoint(),
2010 id.clone(),
2011 Description::new(id)
2012 .title(format!("{name}: runs"))
2013 .summary(format!(
2014 "{about}Each run is recorded: who, which version, under exactly what \
2015 capability, when, and the outcome."
2016 ))
2017 .verb(Verb::Meta)
2018 .action(
2019 params(ActionSpec::new(Verb::Sink))
2020 .summary(format!(
2021 "Run the plan {name} and record the run; answers the run's IRI."
2022 ))
2023 .input(piped_parameters_arg())
2024 .input(as_arg())
2025 .output(PLAIN),
2026 ),
2027 );
2028 Some(PerScript {
2029 result,
2030 runs,
2031 reads,
2032 listed: head.state == State::Published,
2033 })
2034 }
2035}
2036
2037fn contracted(
2043 mut spec: ActionSpec,
2044 inputs: &[ArgSpec],
2045 name: &str,
2046 head: &Head,
2047 version: &Version,
2048) -> ActionSpec {
2049 for input in inputs {
2050 spec = spec.input(input.clone());
2051 }
2052 spec = spec.requires(if head.public {
2053 CAP_RUN.to_string()
2054 } else {
2055 cap_run(name)
2056 });
2057 for scope in &version.requires {
2058 spec = spec.requires(scope.clone());
2059 }
2060 spec
2061}
2062
2063fn piped_parameters_arg() -> ArgSpec {
2065 ArgSpec::new("content")
2066 .summary(
2067 "The parameters as one JSON object, for a pipe (a parameter given both here and \
2068 by name is refused).",
2069 )
2070 .class(XSD_STRING)
2071 .optional()
2072}
2073
2074fn about(source: &str) -> String {
2076 let mut lines = Vec::new();
2077 for line in source.lines() {
2078 let trimmed = line.trim();
2079 let Some(body) = trimmed.strip_prefix('#') else {
2080 if trimmed.is_empty() {
2081 continue;
2082 }
2083 break;
2084 };
2085 let body = body.trim();
2086 if !body.is_empty() && !body.starts_with("@param") {
2087 lines.push(body);
2088 }
2089 }
2090 if lines.is_empty() {
2091 String::new()
2092 } else {
2093 format!("{} ", lines.join(" "))
2094 }
2095}
2096
2097impl Space for ScriptSpace {
2098 fn resolve(&self, request: &Request, scope: &Scope) -> Resolution {
2099 let resolution = self.inner.resolve(request, scope);
2100 let (name, part) = match &resolution {
2101 Resolution::Hit(hit) => {
2102 let part = match hit.endpoint.name() {
2103 "script-result" => Door::Result,
2104 "script-runs" => Door::Runs,
2105 _ => return resolution,
2106 };
2107 match hit.bindings.get("name") {
2108 Some(name) if name::validate(name).is_ok() => (name.to_string(), part),
2109 _ => return resolution,
2110 }
2111 }
2112 Resolution::Miss => return resolution,
2113 };
2114 match self.per_script(&name) {
2115 Some(per) => resolution.map_endpoint(|_| match part {
2116 Door::Result => per.result,
2117 Door::Runs => per.runs,
2118 }),
2119 None => resolution,
2120 }
2121 }
2122
2123 fn entries(&self) -> Option<Vec<SpaceEntry>> {
2124 let mut entries = self.inner.entries().unwrap_or_default();
2125 for name in self.shared.backend.names().unwrap_or_default() {
2126 let Some(per) = self.per_script(&name).filter(|per| per.listed) else {
2127 continue;
2128 };
2129 if per.reads {
2130 entries.push(SpaceEntry::new(
2131 name::part_iri(&name, "result"),
2132 per.result.name(),
2133 ));
2134 }
2135 entries.push(SpaceEntry::new(
2136 name::part_iri(&name, "runs"),
2137 per.runs.name(),
2138 ));
2139 }
2140 Some(entries)
2141 }
2142
2143 fn id(&self) -> Option<Iri> {
2144 self.inner.id()
2145 }
2146
2147 fn topology(&self) -> Topology {
2148 self.inner.topology()
2149 }
2150}
2151
2152fn kind(error: &Error) -> &'static str {
2154 match error {
2155 Error::Unresolved(_) => "unresolved",
2156 Error::MissingArgument(_) => "missing-argument",
2157 Error::InvalidArgument { .. } => "invalid-argument",
2158 Error::Endpoint(_) => "endpoint",
2159 Error::Denied(_) => "denied",
2160 Error::NotFound(_) => "not-found",
2161 Error::Conflict(_) => "conflict",
2162 Error::Timeout(_) => "timeout",
2163 Error::Unavailable(_) => "unavailable",
2164 _ => "other",
2165 }
2166}
2167
2168fn annotate(error: Error, run: &str) -> Error {
2170 let note = |message: String| format!("{message} (run recorded at {run})");
2171 match error {
2172 Error::Endpoint(m) => Error::Endpoint(note(m)),
2173 Error::Denied(m) => Error::Denied(note(m)),
2174 Error::NotFound(m) => Error::NotFound(note(m)),
2175 Error::Conflict(m) => Error::Conflict(note(m)),
2176 Error::Timeout(m) => Error::Timeout(note(m)),
2177 Error::Unavailable(m) => Error::Unavailable(note(m)),
2178 Error::InvalidArgument { name, detail } => Error::InvalidArgument {
2179 name,
2180 detail: note(detail),
2181 },
2182 other => other,
2183 }
2184}
2185
2186struct RunEndpoint {
2191 shared: Arc<SpaceConfig>,
2192}
2193
2194#[async_trait]
2195impl Endpoint for RunEndpoint {
2196 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
2197 let name = name::from_bindings(inv)?;
2198 if !holds(inv, &cap_read(&name)) && !holds(inv, &cap_run(&name)) {
2201 return Err(Error::Denied(format!(
2202 "a run record of urn:script:{name} needs `{}` or `{}`",
2203 cap_read(&name),
2204 cap_run(&name)
2205 )));
2206 }
2207 let id_text = inv
2208 .bindings
2209 .get("id")
2210 .ok_or_else(|| Error::Endpoint("no `id` captured".to_string()))?;
2211 let id = id_text.parse::<u64>().map_err(|_| Error::InvalidArgument {
2212 name: "id".to_string(),
2213 detail: format!("`{id_text}` is not a run number"),
2214 });
2215 let run = match id {
2216 Ok(id) => self.shared.backend.run(&name, id)?,
2217 Err(_) if inv.request.verb == Verb::Exists => None,
2218 Err(e) => return Err(e),
2219 };
2220 match inv.request.verb {
2221 Verb::Exists => Ok(plain(if run.is_some() { "true\n" } else { "false\n" })),
2222 Verb::Source => {
2223 let want = face_of(inv, &RECORD_FACES)?;
2224 let run = run.ok_or_else(|| {
2225 Error::NotFound(format!("urn:script:{name} has no run {id_text}"))
2226 })?;
2227 let finished = run.outcome != Outcome::Running;
2228 let repr = match want {
2229 JSON => json(&run)?,
2230 TURTLE => {
2231 let mut graph = crate::graph::Graph::default();
2232 graph.record(&run);
2233 turtle(graph)?
2234 }
2235 _ => plain(run.render()),
2236 };
2237 Ok(if finished { repr.cacheable() } else { repr })
2239 }
2240 other => Err(unsupported("script-run", other)),
2241 }
2242 }
2243
2244 fn name(&self) -> &str {
2245 "script-run"
2246 }
2247
2248 fn describe(&self) -> Description {
2249 let id = || {
2250 ArgSpec::new("id")
2251 .summary("The run's number, unique per script.")
2252 .class(XSD_INTEGER)
2253 .binding()
2254 };
2255 Description::new("script-run")
2256 .title("One run of a script")
2257 .summary(
2258 "A recorded run: principal, version, capability, start and end, outcome, the \
2259 result (up to 64 KiB) and the host's trace span when it traced. Needs the \
2260 script's read or run grant.",
2261 )
2262 .verb(Verb::Meta)
2263 .action(
2264 ActionSpec::new(Verb::Source)
2265 .summary("The run record, as text, JSON, or a PROV-O graph (Turtle).")
2266 .input(name_arg())
2267 .input(id())
2268 .input(record_as_arg())
2269 .output(PLAIN)
2270 .output(JSON)
2271 .output(TURTLE)
2272 .requires(CAP_ANY),
2273 )
2274 .action(
2275 ActionSpec::new(Verb::Exists)
2276 .summary("Whether the run exists.")
2277 .input(name_arg())
2278 .input(id())
2279 .output(PLAIN)
2280 .requires(CAP_ANY),
2281 )
2282 }
2283}
2284
2285struct EvalEndpoint;
2290
2291#[async_trait]
2292impl Endpoint for EvalEndpoint {
2293 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
2294 if inv.request.verb != Verb::Sink {
2295 return Err(unsupported("script-eval", inv.request.verb));
2296 }
2297 let code = inv.inline_str("content")?;
2298 let language_text = optional(inv, "language")?;
2299 let language = language_text
2300 .map(Language::parse)
2301 .transpose()?
2302 .unwrap_or(Language::Lisp);
2303 if language == Language::Sparql {
2304 return Err(Error::InvalidArgument {
2305 name: "language".to_string(),
2306 detail: "SPARQL through urn:script:eval is the SPARQL Protocol face (ledger \
2307 #955), not in this version: publish the query as a script and read \
2308 its `…:result`"
2309 .to_string(),
2310 });
2311 }
2312 if language == Language::Plan {
2313 return Err(Error::InvalidArgument {
2316 name: "language".to_string(),
2317 detail: format!(
2318 "a plan supplied ad hoc runs at {} (`in` = the plan), under the caller's \
2319 own capability; to keep it, publish it as a script",
2320 plan::EVAL
2321 ),
2322 });
2323 }
2324 if let Some(save) = optional(inv, "save")? {
2325 let save = save.trim();
2326 name::validate(save)?;
2327 let mut request = Request::new(Verb::Sink, iri(&name::script_iri(save))?)
2331 .with_arg("content", inline(code))
2332 .with_arg("language", inline(language.as_str()))
2333 .with_arg("state", inline("draft"));
2334 if let Some(requires) = optional(inv, "requires")? {
2335 request = request.with_arg("requires", inline(requires));
2336 }
2337 inv.issue(request).await?;
2338 }
2339 let answer = inv
2342 .issue(lisp_request(code, optional(inv, "data")?)?)
2343 .await?;
2344 Ok(Representation::new(answer.repr_type, answer.bytes))
2345 }
2346
2347 fn name(&self) -> &str {
2348 "script-eval"
2349 }
2350
2351 fn describe(&self) -> Description {
2352 Description::new("script-eval")
2353 .title("Run code you supply")
2354 .summary(
2355 "Run typed or pasted code as an anonymous script under the caller's OWN \
2356 capability and nothing more; answers its value. `save=` keeps it as a draft \
2357 of that script (which needs that script's write grant).",
2358 )
2359 .verb(Verb::Meta)
2360 .action(
2361 ActionSpec::new(Verb::Sink)
2362 .summary("Run the code.")
2363 .input(
2364 ArgSpec::new("content")
2365 .summary("The code (piped).")
2366 .class(XSD_STRING),
2367 )
2368 .input(
2369 ArgSpec::new("language")
2370 .summary(
2371 "The code's language: `lisp` (SPARQL here is the Protocol \
2372 face, not in this version; a plan runs at urn:plan:eval).",
2373 )
2374 .class(XSD_STRING)
2375 .one_of(Language::ALL.map(Language::as_str))
2376 .default_value("lisp")
2377 .optional(),
2378 )
2379 .input(data_arg("data", DATA_SUMMARY))
2380 .input(
2381 ArgSpec::new("save")
2382 .summary(
2383 "A script name: also keep the code as a draft at \
2384 `urn:script:{save}`. Needs `urn:cap:script:write:{save}`.",
2385 )
2386 .class(XSD_STRING)
2387 .optional(),
2388 )
2389 .input(
2390 ArgSpec::new("requires")
2391 .summary("With `save=`: the draft's declared capability.")
2392 .class(XSD_STRING)
2393 .optional(),
2394 )
2395 .output(PLAIN)
2396 .requires(CAP_LISP),
2397 )
2398 }
2399}
2400
2401#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2407pub struct CatalogEntry {
2408 pub name: String,
2410 pub iri: String,
2412 pub state: Option<State>,
2414 pub public: Option<bool>,
2416 pub version: Option<String>,
2418 #[serde(rename = "lastRun")]
2420 pub last_run: Option<LastRun>,
2421 pub error: Option<String>,
2423}
2424
2425#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2427pub struct LastRun {
2428 pub iri: String,
2430 pub status: String,
2432 pub ended: Option<u64>,
2434}
2435
2436#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2438pub struct Catalog {
2439 pub schema: u32,
2441 pub scripts: Vec<CatalogEntry>,
2443}
2444
2445struct CatalogEndpoint {
2446 shared: Arc<SpaceConfig>,
2447}
2448
2449#[async_trait]
2450impl Endpoint for CatalogEndpoint {
2451 async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
2452 if inv.request.verb != Verb::Source {
2453 return Err(unsupported("script-catalog", inv.request.verb));
2454 }
2455 let want = face_of(inv, &RECORD_FACES)?;
2456 let backend = &self.shared.backend;
2457 let mut scripts = Vec::new();
2458 let mut last_runs = Vec::new();
2460 for name in backend.names()? {
2461 let own = holds(inv, &cap_read(&name));
2462 let mut entry = CatalogEntry {
2463 iri: name::script_iri(&name),
2464 name: name.clone(),
2465 state: None,
2466 public: None,
2467 version: None,
2468 last_run: None,
2469 error: None,
2470 };
2471 let mut last = None;
2472 match backend.head(&name) {
2473 Err(e) if own => entry.error = Some(e.to_string()),
2476 Ok(Some(head))
2478 if sight(inv, &self.shared, &head, &head.version) == Sight::Hidden =>
2479 {
2480 continue
2481 }
2482 Ok(Some(head)) if own || (is_public(&head) && holds(inv, CAP_READ_PUBLIC)) => {
2483 entry.state = Some(head.state);
2484 entry.public = Some(head.public);
2485 entry.version = Some(head.version);
2486 last = backend.last_run(&name).ok().flatten();
2487 entry.last_run = last.as_ref().map(|run| LastRun {
2488 iri: run.iri(),
2489 status: match run.outcome {
2490 Outcome::Running => "running",
2491 Outcome::Ok => "ok",
2492 Outcome::Failed { .. } => "failed",
2493 }
2494 .to_string(),
2495 ended: run.ended,
2496 });
2497 }
2498 _ => continue,
2499 }
2500 last_runs.push(last);
2501 scripts.push(entry);
2502 }
2503 if want == TURTLE {
2504 let mut graph = crate::graph::Graph::default();
2505 for (entry, last) in scripts.iter().zip(&last_runs) {
2506 graph.script(&entry.name, entry.version.as_deref(), last.as_ref());
2507 }
2508 return turtle(graph);
2509 }
2510 if want == JSON {
2511 return json(&Catalog {
2514 schema: SCHEMA,
2515 scripts,
2516 });
2517 }
2518 let mut text = String::new();
2519 for entry in &scripts {
2520 match (&entry.error, entry.state) {
2521 (Some(error), _) => text.push_str(&format!("{} BROKEN: {error}\n", entry.iri)),
2522 (None, Some(state)) => {
2523 let version = entry.version.as_deref().unwrap_or("");
2524 text.push_str(&format!(
2525 "{} {}{} {}",
2526 entry.iri,
2527 state.as_str(),
2528 if entry.public == Some(true) {
2529 " public"
2530 } else {
2531 ""
2532 },
2533 &version[..version.len().min(19)],
2534 ));
2535 if let Some(run) = &entry.last_run {
2536 text.push_str(&format!(
2537 " last run {} {} {}",
2538 run.iri,
2539 run.status,
2540 model::when(run.ended)
2541 ));
2542 }
2543 text.push('\n');
2544 }
2545 _ => {}
2546 }
2547 }
2548 if scripts.is_empty() {
2549 text.push_str("no scripts this capability may read\n");
2550 }
2551 Ok(plain(text))
2552 }
2553
2554 fn name(&self) -> &str {
2555 "script-catalog"
2556 }
2557
2558 fn describe(&self) -> Description {
2559 Description::new("script-catalog")
2560 .title("Scripts")
2561 .summary(
2562 "Every script the caller may read: state, public flag, head version and last \
2563 run. Live (never cached). A host that wants `urn:host:scripts` aliases it \
2564 here.",
2565 )
2566 .verb(Verb::Meta)
2567 .action(
2568 ActionSpec::new(Verb::Source)
2569 .summary("The catalog, as text, JSON, or a graph (Turtle).")
2570 .input(record_as_arg())
2571 .output(PLAIN)
2572 .output(JSON)
2573 .output(TURTLE)
2574 .requires(CAP_READ),
2575 )
2576 }
2577}
2578
2579fn unsupported(id: &str, verb: Verb) -> Error {
2580 Error::Endpoint(format!("{id} does not answer {verb:?}"))
2581}