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