1use areev_cal::classify::{classify, StatementClass};
27use areev_cal::{parse, CalExecutor, CalExecutorConfig, CalStoreFacade, AreevFacade};
28use areev_core::error::{AreevError, Hash};
29use areev_core::format::deserialize::DeserializedGrain;
30use areev_core::authz::Verb;
31use areev_core::types::{Fact, Grain, GrainType, Workflow, WorkflowEdge};
32use areev_store::{Areev, TelemetryMode, OP_FORGET};
33use serde_json::{Map, Value};
34use std::collections::BTreeSet;
35use areev_loop::error::{Error as WErr, Result as WResult};
36use areev_loop::{
37 BudgetUsage, Capabilities, GrainAccess, GrainRecord, GrainSpec, HeadGroup, OmsSubstrate,
38 QueryUsage, ReadOpts, SubstrateRead, TelemetryView,
39};
40
41const LOOP_NS: &str = "areev-loop";
43const STATE_SUBJECT: &str = "__loop_state__";
45const STATE_RELATION: &str = "state";
46const MAX_SCAN: usize = 1_000_000;
48
49fn we(e: AreevError) -> WErr {
50 WErr::Substrate(e.to_string())
51}
52
53fn werr(msg: &str) -> WErr {
56 WErr::Substrate(msg.to_string())
57}
58
59fn require_read(f: &AreevFacade, ns: &str) -> WResult<()> {
60 f.authz().check(Verb::Read, ns).map_err(we)
61}
62
63fn may_read(f: &AreevFacade, ns: &str) -> bool {
64 f.authz().allows(Verb::Read, ns)
65}
66
67pub struct AreevSubstrate {
69 facade: AreevFacade,
70}
71
72impl AreevSubstrate {
73 pub fn new(store: Areev, session_ns: Option<String>) -> Self {
76 let ns = session_ns.unwrap_or_else(|| LOOP_NS.to_string());
77 AreevSubstrate {
78 facade: AreevFacade::with_session(store, Some(ns), None),
79 }
80 }
81
82 pub fn into_store(self) -> Areev {
84 self.facade.into_inner()
85 }
86}
87
88pub struct BorrowedSubstrate<'a> {
91 facade: &'a AreevFacade,
92}
93
94impl<'a> BorrowedSubstrate<'a> {
95 pub fn new(facade: &'a AreevFacade) -> Self {
96 BorrowedSubstrate { facade }
97 }
98}
99
100impl AreevSubstrate {
101 fn facade_ref(&self) -> &AreevFacade {
102 &self.facade
103 }
104
105 pub fn facade(&self) -> &AreevFacade {
108 &self.facade
109 }
110}
111
112impl BorrowedSubstrate<'_> {
113 fn facade_ref(&self) -> &AreevFacade {
114 self.facade
115 }
116}
117
118macro_rules! impl_substrate {
121 ($ty:ty) => {
122 impl SubstrateRead for $ty {
123 fn capabilities(&self) -> Capabilities {
124 caps(self.facade_ref())
125 }
126 fn grains_of_type(
127 &self,
128 grain_type: &str,
129 namespace: Option<&str>,
130 opts: ReadOpts,
131 ) -> WResult<Vec<GrainRecord>> {
132 grains_of_type(self.facade_ref(), grain_type, namespace, opts)
133 }
134 fn grain(&self, hash: &str) -> WResult<Option<GrainRecord>> {
135 grain(self.facade_ref(), hash)
136 }
137 fn validate_plan(&self, workflow: &Value) -> WResult<()> {
138 validate_plan(workflow)
139 }
140 fn tool_evalset(&self, tool: &str) -> WResult<Option<String>> {
141 tool_evalset(self.facade_ref(), tool)
142 }
143 fn heads(&self, namespace: Option<&str>) -> WResult<Vec<HeadGroup>> {
144 heads(self.facade_ref(), namespace)
145 }
146 fn telemetry(&self, namespace: Option<&str>) -> WResult<Option<TelemetryView>> {
147 telemetry(self.facade_ref(), namespace)
148 }
149 }
150
151 impl OmsSubstrate for $ty {
152 fn put_grain(&mut self, spec: &GrainSpec) -> WResult<String> {
153 put_grain(self.facade_ref(), spec)
154 }
155 fn supersede(
156 &mut self,
157 target_hash: &str,
158 spec: &GrainSpec,
159 _j: &str,
160 ) -> WResult<String> {
161 supersede_op(self.facade_ref(), target_hash, spec)
162 }
163 fn retract(&mut self, hash: &str, _reason: &str) -> WResult<()> {
164 retract_op(self.facade_ref(), hash)
165 }
166 fn execute_cal(&mut self, cal: &str) -> WResult<Vec<Value>> {
167 execute_cal(self.facade_ref(), cal)
168 }
169 fn validate_cal(&self, cal: &str) -> WResult<()> {
170 validate_cal(cal)
171 }
172 fn definition_inverse(&self, statement: &str) -> WResult<Option<String>> {
173 definition_inverse(self.facade_ref(), statement)
174 }
175 fn load_state(&self) -> WResult<Value> {
176 load_state(self.facade_ref())
177 }
178 fn store_state(&mut self, state: &Value) -> WResult<()> {
179 store_state(self.facade_ref(), state)
180 }
181 fn put_blob(&mut self, bytes: &[u8]) -> WResult<String> {
182 put_blob_op(self.facade_ref(), bytes)
183 }
184 fn get_blob(&mut self, address: &str) -> WResult<Vec<u8>> {
185 get_blob_op(self.facade_ref(), address)
186 }
187 }
188 };
189}
190
191impl_substrate!(AreevSubstrate);
192impl_substrate!(BorrowedSubstrate<'_>);
193
194fn recent_ns(f: &AreevFacade, ns: &str, gt: GrainType) -> WResult<Vec<DeserializedGrain>> {
197 require_read(f, ns)?;
201 f.with_store(|m| m.recent(ns, Some(gt), MAX_SCAN))
202 .map_err(we)
203}
204
205fn all_grains(f: &AreevFacade, gt: GrainType) -> WResult<Vec<DeserializedGrain>> {
207 let ops = f.with_store(|m| m.changes_since(0, MAX_SCAN)).map_err(we)?;
208 let mut seen = BTreeSet::new();
209 let mut out = Vec::new();
210 for op in ops {
211 if op.op == OP_FORGET || !seen.insert(op.hash.to_hex()) {
212 continue;
213 }
214 if let Ok(g) = f.with_store(|m| m.get(&op.hash)) {
215 if g.grain_type == gt && may_read(f, &grain_namespace(&g)) {
216 out.push(g);
217 }
218 }
219 }
220 Ok(out)
221}
222
223fn read_user_type(
224 f: &AreevFacade,
225 gt: GrainType,
226 namespace: Option<&str>,
227 opts: ReadOpts,
228) -> WResult<Vec<GrainRecord>> {
229 let explicit = namespace.is_some();
230 let raw = match namespace {
231 Some(ns) => recent_ns(f, ns, gt)?,
232 None => all_grains(f, gt)?,
233 };
234 let superseded = superseded_set(&raw);
235 let mut out = Vec::new();
236 for g in raw {
237 let ns = grain_namespace(&g);
238 if !explicit && (ns == LOOP_NS || ns.starts_with("agent:")) {
250 continue;
251 }
252 if opts.live_only && superseded.contains(&g.hash.to_hex()) {
253 continue;
254 }
255 let created = grain_created_ms(&g);
256 if opts.since_ms.is_some_and(|s| created < s) {
257 continue;
258 }
259 out.push(map_user_grain(&g, ns, created));
260 }
261 Ok(out)
262}
263
264fn read_loop(
266 f: &AreevFacade,
267 relation: &str,
268 out_type: &str,
269 opts: ReadOpts,
270) -> WResult<Vec<GrainRecord>> {
271 let facts = recent_ns(f, LOOP_NS, GrainType::Fact)?;
272 let mut out = Vec::new();
273 for fact in facts {
274 if fact.get_str("relation") != Some(relation) {
275 continue;
276 }
277 let Some(payload) = fact.get_str("object") else {
278 continue;
279 };
280 let fields: Map<String, Value> = match serde_json::from_str(payload) {
281 Ok(Value::Object(m)) => m,
282 _ => continue,
283 };
284 let created = grain_created_ms(&fact);
285 if opts.since_ms.is_some_and(|s| created < s) {
286 continue;
287 }
288 out.push(loop_record(
289 &fact.hash.to_hex(),
290 out_type,
291 created,
292 fields,
293 ));
294 }
295 Ok(out)
296}
297
298fn caps(f: &AreevFacade) -> Capabilities {
299 Capabilities {
300 forks: true,
301 telemetry: f.with_store(|m| m.telemetry_mode()) != TelemetryMode::Off,
303 embeddings: f.with_store(|m| m.declared_embedding().is_some()),
304 plans: true,
308 code: true,
309 }
310}
311
312fn validate_plan(workflow: &Value) -> WResult<()> {
317 let fields = workflow
318 .as_object()
319 .ok_or_else(|| werr("a workflow body must be a JSON object"))?;
320 let wf = strict_workflow(fields)?;
321 areev_run_core::plan::PlanGraph::build(&wf)
322 .map(|_| ())
323 .map_err(|e| werr(&format!("proposed plan is not runnable: {e}")))
324}
325
326fn strict_workflow(fields: &Map<String, Value>) -> WResult<Workflow> {
335 let nodes: Vec<String> = match fields.get("nodes") {
336 Some(Value::Array(a)) => a
337 .iter()
338 .map(|v| {
339 v.as_str()
340 .map(str::to_string)
341 .ok_or_else(|| werr("every workflow node must be a string"))
342 })
343 .collect::<WResult<Vec<String>>>()?,
344 _ => return Err(werr("a workflow needs a 'nodes' array")),
345 };
346 let mut wf = Workflow::new(nodes);
347 if let Some(v) = fields.get("edges") {
348 let arr = v
349 .as_array()
350 .ok_or_else(|| werr("workflow 'edges' must be an array"))?;
351 for e in arr {
352 let (Some(src), Some(dst)) = (
353 e.get("src").and_then(Value::as_str),
354 e.get("dst").and_then(Value::as_str),
355 ) else {
356 return Err(werr("every workflow edge needs a 'src' and a 'dst'"));
357 };
358 let cond = match e.get("cond") {
359 None | Some(Value::Null) => None,
360 Some(Value::String(c)) => Some(c.clone()),
361 Some(_) => return Err(werr("an edge 'cond' must be a string")),
362 };
363 let max_cycles = match e.get("max_cycles") {
364 None | Some(Value::Null) => None,
365 Some(v) => Some(
366 v.as_u64()
367 .and_then(|n| u32::try_from(n).ok())
368 .ok_or_else(|| werr("an edge 'max_cycles' must be a small integer"))?,
369 ),
370 };
371 wf.edges.push(WorkflowEdge {
372 src: src.to_string(),
373 dst: dst.to_string(),
374 cond,
375 max_cycles,
376 });
377 }
378 }
379 if let Some(v) = fields.get("bindings") {
380 let obj = v
381 .as_object()
382 .ok_or_else(|| werr("workflow 'bindings' must be an object"))?;
383 for (node, hash) in obj {
384 let h = hash
385 .as_str()
386 .ok_or_else(|| werr("a workflow binding must be a tool hash string"))?;
387 wf.bindings.insert(node.clone(), h.to_string());
388 }
389 }
390 if let Some(v) = fields.get("retries") {
391 let obj = v
392 .as_object()
393 .ok_or_else(|| werr("workflow 'retries' must be an object"))?;
394 for (node, max) in obj {
395 let m = max
396 .as_u64()
397 .and_then(|n| u32::try_from(n).ok())
398 .ok_or_else(|| werr("a workflow retry count must be a small integer"))?;
399 wf.retries.insert(node.clone(), m);
400 }
401 }
402 Ok(wf)
403}
404
405fn tool_evalset(f: &AreevFacade, tool: &str) -> WResult<Option<String>> {
413 let defs = grains_of_type(f, "tool", None, ReadOpts::default())?;
414 Ok(defs
415 .into_iter()
416 .filter(|g| {
417 g.str_field("kind") == Some("definition") && g.tool_name() == Some(tool)
418 })
419 .filter_map(|g| {
420 g.str_field("evalset_hash")
421 .filter(|h| !h.trim().is_empty())
422 .map(str::to_string)
423 })
424 .next())
425}
426
427fn telemetry(f: &AreevFacade, namespace: Option<&str>) -> WResult<Option<TelemetryView>> {
431 if f.with_store(|m| m.telemetry_mode()) == TelemetryMode::Off {
432 return Ok(None);
433 }
434 if let Some(ns) = namespace {
435 require_read(f, ns)?;
436 }
437 let access = f.with_store(|m| m.telemetry_access_stats(namespace)).map_err(we)?;
438 let queries = f.with_store(|m| m.telemetry_query_stats(namespace)).map_err(we)?;
439 let budget = if may_read(f, "*") {
443 f.with_store(|m| m.telemetry_budget_stats()).map_err(we)?
444 } else {
445 Default::default()
446 };
447 Ok(Some(TelemetryView {
448 access: access
449 .into_iter()
450 .filter(|a| may_read(f, &a.ns))
451 .map(|a| GrainAccess { hash: a.hash, recall_count: a.recall_count, last_ms: a.last_ms })
452 .collect(),
453 queries: queries
454 .into_iter()
455 .filter(|q| may_read(f, &q.ns))
456 .map(|q| QueryUsage {
457 sample: q.sample,
458 run_count: q.run_count,
459 empty_count: q.empty_count,
460 sum_results: q.sum_results,
461 last_ms: q.last_ms,
462 })
463 .collect(),
464 budget: BudgetUsage {
465 sample_count: budget.sample_count,
466 overflow_count: budget.overflow_count,
467 },
468 }))
469}
470
471fn grains_of_type(
472 f: &AreevFacade,
473 grain_type: &str,
474 namespace: Option<&str>,
475 opts: ReadOpts,
476) -> WResult<Vec<GrainRecord>> {
477 match grain_type {
478 "recommendation" => read_loop(f, "loop_recommendation", "recommendation", opts),
479 "fact" => read_user_type(f, GrainType::Fact, namespace, opts),
480 "event" => read_user_type(f, GrainType::Event, namespace, opts),
481 "tool" => read_user_type(f, GrainType::Tool, namespace, opts),
482 "observation" if namespace == Some(LOOP_NS) => {
486 read_loop(f, "loop_audit", "observation", opts)
487 }
488 "observation" => read_user_type(f, GrainType::Observation, namespace, opts),
489 "skill" => read_user_type(f, GrainType::Skill, namespace, opts),
490 "goal" => read_user_type(f, GrainType::Goal, namespace, opts),
491 other => Err(WErr::Substrate(format!("unsupported grain type {other:?}"))),
492 }
493}
494
495fn grain(f: &AreevFacade, hash: &str) -> WResult<Option<GrainRecord>> {
496 let h = Hash::from_hex(hash).map_err(we)?;
497 let g = match f.with_store(|m| m.get(&h)) {
498 Ok(g) => g,
499 Err(AreevError::NotFound(_)) => return Ok(None),
500 Err(e) => return Err(we(e)),
501 };
502 let created = grain_created_ms(&g);
503 let superseded_by = f
509 .with_store(|m| m.supersession_map(&[h]))
510 .map_err(we)?
511 .get(&h)
512 .map(|s| s.to_hex());
513 let ns = grain_namespace(&g);
514 if ns == LOOP_NS {
519 if let Some(rel) = g.get_str("relation") {
520 if let Some(out_type) = loop_relation_type(rel) {
521 require_read(f, LOOP_NS)?;
522 if let Some(Value::Object(fields)) = g
523 .get_str("object")
524 .and_then(|p| serde_json::from_str(p).ok())
525 {
526 let mut rec = loop_record(hash, out_type, created, fields);
527 rec.superseded_by = superseded_by;
528 return Ok(Some(rec));
529 }
530 }
531 }
532 }
533 require_read(f, &ns)?;
534 let mut rec = map_user_grain(&g, ns, created);
535 rec.superseded_by = superseded_by;
536 Ok(Some(rec))
537}
538
539fn heads(f: &AreevFacade, namespace: Option<&str>) -> WResult<Vec<HeadGroup>> {
540 let forks = f.with_store(|m| m.open_forks()).map_err(we)?;
541 Ok(forks
542 .into_iter()
543 .filter(|fg| fg.namespace != LOOP_NS)
544 .filter(|fg| may_read(f, &fg.namespace))
545 .filter(|fg| namespace.is_none_or(|ns| fg.namespace == ns))
546 .map(|fg| HeadGroup {
547 entity: format!("{}/{}", fg.subject, fg.relation),
548 heads: fg.heads.iter().map(|h| h.to_hex()).collect(),
549 })
550 .collect())
551}
552
553const MAX_LOOP_BLOB: usize = 4 * 1024 * 1024;
560
561fn put_blob_op(f: &AreevFacade, bytes: &[u8]) -> WResult<String> {
562 f.authz().check(Verb::Write, LOOP_NS).map_err(we)?;
563 if bytes.len() > MAX_LOOP_BLOB {
564 return Err(WErr::Substrate(format!(
565 "blob is {} bytes (loop cap {MAX_LOOP_BLOB})",
566 bytes.len()
567 )));
568 }
569 f.with_store(|m| m.put_blob(bytes)).map_err(we)
570}
571
572fn get_blob_op(f: &AreevFacade, address: &str) -> WResult<Vec<u8>> {
573 require_read(f, LOOP_NS)?;
574 f.with_store(|m| m.get_blob(address)).map_err(we)
575}
576
577fn put_grain(f: &AreevFacade, spec: &GrainSpec) -> WResult<String> {
578 if spec.grain_type == "fact" {
584 if let (Some(subject), Some(relation), Some(object)) = (
585 spec.fields.get("subject").and_then(Value::as_str),
586 spec.fields.get("relation").and_then(Value::as_str),
587 spec.fields.get("object").and_then(Value::as_str),
588 ) {
589 let mut fact = Fact::new(subject, relation, object)
590 .namespace(LOOP_NS)
591 .confidence(1.0);
592 fact.common.created_at = spec_created_ms(spec);
593 for (k, v) in &spec.fields {
594 if !matches!(k.as_str(), "subject" | "relation" | "object") {
595 fact.common.extra_fields.insert(k.clone(), v.clone());
596 }
597 }
598 return f.with_store(|m| m.add(&fact)).map(|h| h.to_hex()).map_err(we);
599 }
600 }
601 let payload = serde_json::to_string(&spec.fields)
602 .map_err(|e| WErr::Substrate(format!("encode grain: {e}")))?;
603 let mut fact = Fact::new(
604 &unique_subject(&payload),
605 loop_relation(&spec.grain_type),
606 &payload,
607 )
608 .namespace(LOOP_NS)
609 .confidence(1.0);
610 fact.common.created_at = spec_created_ms(spec);
611 f.with_store(|m| m.add(&fact))
612 .map(|h| h.to_hex())
613 .map_err(we)
614}
615
616fn supersede_op(f: &AreevFacade, target_hash: &str, spec: &GrainSpec) -> WResult<String> {
617 let payload = serde_json::to_string(&spec.fields)
618 .map_err(|e| WErr::Substrate(format!("encode grain: {e}")))?;
619 let old = Hash::from_hex(target_hash).map_err(we)?;
620 let mut fact = Fact::new(
621 &unique_subject(&payload),
622 loop_relation(&spec.grain_type),
623 &payload,
624 )
625 .namespace(LOOP_NS);
626 fact.common.created_at = spec_created_ms(spec);
627 f.with_store(|m| m.supersede(&old, &mut fact))
628 .map(|h| h.to_hex())
629 .map_err(we)
630}
631
632fn spec_created_ms(spec: &GrainSpec) -> Option<i64> {
639 spec.fields
640 .get("created_at_ms")
641 .or_else(|| spec.fields.get("at_ms"))
642 .and_then(Value::as_i64)
643}
644
645fn retract_op(f: &AreevFacade, hash: &str) -> WResult<()> {
646 let h = Hash::from_hex(hash).map_err(we)?;
649 f.with_store(|m| m.forget(&h)).map_err(we)
650}
651
652fn execute_cal(f: &AreevFacade, cal: &str) -> WResult<Vec<Value>> {
653 let mut rows = Vec::new();
658 for line in cal.lines() {
659 let line = line.trim();
660 if line.is_empty() {
661 continue;
662 }
663 let (keyword, rest) = split_keyword(line);
664 match keyword.to_ascii_uppercase().as_str() {
665 "FORGET" => {
666 let h = Hash::from_hex(rest.trim()).map_err(we)?;
667 f.cal_delete(&h, Some("areev-loop apply")).map_err(we)?;
670 }
671 "ADD" => {
672 let (gtype, fields) = parse_type_and_json(rest)?;
673 let h = f.cal_add(>ype, &fields).map_err(we)?;
674 rows.push(serde_json::json!({ "hash": h.to_hex() }));
675 }
676 "SUPERSEDE" => {
677 let (target, after) = rest
678 .split_once(" WITH ")
679 .ok_or_else(|| WErr::CalUnsupported(format!("malformed SUPERSEDE: {line}")))?;
680 let (gtype, fields) = parse_type_and_json(after)?;
681 let old = Hash::from_hex(target.trim()).map_err(we)?;
682 let h = f.cal_supersede(&old, >ype, &fields).map_err(we)?;
683 rows.push(serde_json::json!({ "hash": h.to_hex() }));
684 }
685 _ => {
686 let parsed = parse(line).map_err(|e| WErr::CalUnsupported(e.to_string()))?;
693 if classify(&parsed.statement) == StatementClass::Destructive {
694 return Err(WErr::CalUnsupported(format!(
695 "destructive CAL beyond FORGET <hash> is not executable through the loop substrate: {line}"
696 )));
697 }
698 let ex = CalExecutor::new(CalExecutorConfig::default());
699 let res = ex
700 .execute(line, f)
701 .map_err(|e| WErr::CalUnsupported(e.to_string()))?;
702 let payload = serde_json::to_value(&res.result)
703 .map_err(|e| WErr::Substrate(format!("encode CAL result: {e}")))?;
704 let mut hs = Vec::new();
705 collect_hashes(&payload, &mut hs);
706 rows.extend(hs.into_iter().map(|h| serde_json::json!({ "hash": h })));
707 }
708 }
709 }
710 Ok(rows)
711}
712
713fn definition_inverse(f: &AreevFacade, statement: &str) -> WResult<Option<String>> {
727 let up = statement.trim_start().to_ascii_uppercase();
728 let (kind, after) = if let Some(rest) = up.strip_prefix("DEFINE QUERY") {
729 ("QUERY", &statement.trim_start()[statement.trim_start().len() - rest.len()..])
730 } else if let Some(rest) = up.strip_prefix("DEFINE TEMPLATE") {
731 ("TEMPLATE", &statement.trim_start()[statement.trim_start().len() - rest.len()..])
732 } else {
733 return Ok(None);
734 };
735 let name = after
737 .trim_start()
738 .trim_start_matches('"')
739 .split(['"', '(', ' ', '\t'])
740 .next()
741 .unwrap_or("")
742 .trim()
743 .to_string();
744 if name.is_empty() {
745 return Ok(None);
746 }
747 if kind == "QUERY" {
748 match f.get_query(&name) {
749 Some(e) if e.builtin => Ok(None),
751 Some(e) => {
752 let params = if e.params.is_empty() {
753 String::new()
754 } else {
755 let list: Vec<String> =
756 e.params.iter().map(|p| format!("${}", p.name)).collect();
757 format!("({})", list.join(", "))
758 };
759 Ok(Some(format!(
760 "DEFINE QUERY \"{name}\"{params} DESCRIPTION \"{}\" AS {{ {} }}",
761 e.description.replace('"', "'"),
762 e.body
763 )))
764 }
765 None => Ok(Some(format!("DROP QUERY \"{name}\""))),
766 }
767 } else {
768 match f.get_template(&name) {
769 Some(t) if t.builtin => Ok(None),
770 Some(t) => Ok(Some(format!(
771 "DEFINE TEMPLATE \"{name}\" DESCRIPTION \"{}\" AS {{ {} }}",
772 t.description.replace('"', "'"),
773 t.source
774 ))),
775 None => Ok(Some(format!("DROP TEMPLATE \"{name}\""))),
776 }
777 }
778}
779
780fn validate_cal(cal: &str) -> WResult<()> {
781 for line in cal.lines() {
782 let line = line.trim();
783 if line.is_empty() {
784 continue;
785 }
786 let (keyword, rest) = split_keyword(line);
787 match keyword.to_ascii_uppercase().as_str() {
788 "ADD" | "SUPERSEDE" => {}
789 "FORGET" => {
792 Hash::from_hex(rest.trim()).map_err(|_| {
793 WErr::CalUnsupported(format!(
794 "loop FORGET takes a single grain hash: {line}"
795 ))
796 })?;
797 }
798 _ => {
799 let parsed = parse(line).map_err(|e| WErr::CalUnsupported(e.to_string()))?;
800 if classify(&parsed.statement) == StatementClass::Destructive {
804 return Err(WErr::CalUnsupported(format!(
805 "destructive CAL beyond FORGET <hash> is not executable through the loop substrate: {line}"
806 )));
807 }
808 }
809 }
810 }
811 Ok(())
812}
813
814fn load_state(f: &AreevFacade) -> WResult<Value> {
815 let head = f
816 .with_store(|m| m.latest(LOOP_NS, STATE_SUBJECT, STATE_RELATION))
817 .map_err(we)?;
818 match head.as_ref().and_then(|g| g.get_str("object")) {
819 Some(json) => serde_json::from_str(json)
820 .map_err(|e| WErr::Substrate(format!("decode loop state: {e}"))),
821 None => Ok(Value::Null),
822 }
823}
824
825fn store_state(f: &AreevFacade, state: &Value) -> WResult<()> {
839 let json = serde_json::to_string(state)
840 .map_err(|e| WErr::Substrate(format!("encode loop state: {e}")))?;
841 let existing = f
842 .with_store(|m| m.latest(LOOP_NS, STATE_SUBJECT, STATE_RELATION))
843 .map_err(we)?;
844 f.with_store(|m| {
845 let mut fact = Fact::new(STATE_SUBJECT, STATE_RELATION, &json).namespace(LOOP_NS);
846 match &existing {
847 Some(g) => m.supersede(&g.hash, &mut fact).map(|_| ()),
848 None => m.add(&fact).map(|_| ()),
849 }
850 })
851 .map_err(we)
852}
853
854fn loop_relation(grain_type: &str) -> &'static str {
857 match grain_type {
858 "recommendation" => "loop_recommendation",
859 _ => "loop_audit", }
861}
862
863fn loop_relation_type(relation: &str) -> Option<&'static str> {
864 match relation {
865 "loop_recommendation" => Some("recommendation"),
866 "loop_audit" => Some("observation"),
867 _ => None,
868 }
869}
870
871fn unique_subject(payload: &str) -> String {
874 use std::hash::{Hash as _, Hasher};
875 let mut h = std::collections::hash_map::DefaultHasher::new();
876 payload.hash(&mut h);
877 format!("w{:016x}", h.finish())
878}
879
880fn grain_namespace(g: &DeserializedGrain) -> String {
881 g.get_str("namespace").unwrap_or("").to_string()
882}
883
884fn grain_created_ms(g: &DeserializedGrain) -> i64 {
885 g.get_i64("created_at")
886 .unwrap_or_else(|| g.header.created_at_sec as i64 * 1000)
887}
888
889fn map_user_grain(g: &DeserializedGrain, namespace: String, created_ms: i64) -> GrainRecord {
890 GrainRecord {
891 hash: g.hash.to_hex(),
892 grain_type: g.grain_type.as_str().to_string(),
893 namespace,
894 created_at_ms: created_ms,
895 valid_to_ms: g.get_i64("valid_to"),
896 superseded_by: None,
897 fields: g.fields.clone().into_iter().collect(),
898 }
899}
900
901fn loop_record(
902 hash: &str,
903 grain_type: &str,
904 created_ms: i64,
905 fields: Map<String, Value>,
906) -> GrainRecord {
907 let valid_to_ms = fields.get("valid_to_ms").and_then(Value::as_i64);
908 GrainRecord {
909 hash: hash.to_string(),
910 grain_type: grain_type.to_string(),
911 namespace: LOOP_NS.to_string(),
912 created_at_ms: created_ms,
913 valid_to_ms,
914 superseded_by: None,
915 fields,
916 }
917}
918
919fn superseded_set(grains: &[DeserializedGrain]) -> BTreeSet<String> {
921 let mut set = BTreeSet::new();
922 for g in grains {
923 for parent in derived_parents(g) {
924 set.insert(parent);
925 }
926 }
927 set
928}
929
930fn derived_parents(g: &DeserializedGrain) -> Vec<String> {
931 match g.fields.get("derived_from") {
932 Some(Value::String(s)) => vec![s.clone()],
933 Some(Value::Array(a)) => a
934 .iter()
935 .filter_map(|v| v.as_str().map(str::to_string))
936 .collect(),
937 _ => Vec::new(),
938 }
939}
940
941fn split_keyword(line: &str) -> (&str, &str) {
942 match line.split_once(char::is_whitespace) {
943 Some((k, rest)) => (k, rest.trim_start()),
944 None => (line, ""),
945 }
946}
947
948fn parse_type_and_json(s: &str) -> WResult<(String, Map<String, Value>)> {
950 let brace = s
951 .find('{')
952 .ok_or_else(|| WErr::CalUnsupported(format!("missing JSON object in {s:?}")))?;
953 let gtype = s[..brace].trim().to_string();
954 if gtype.is_empty() {
955 return Err(WErr::CalUnsupported(format!("missing grain type in {s:?}")));
956 }
957 let value: Value = serde_json::from_str(s[brace..].trim())
958 .map_err(|e| WErr::CalUnsupported(format!("bad JSON in {s:?}: {e}")))?;
959 match value {
960 Value::Object(m) => Ok((gtype, m)),
961 _ => Err(WErr::CalUnsupported(format!("JSON not an object in {s:?}"))),
962 }
963}
964
965fn collect_hashes(v: &Value, out: &mut Vec<String>) {
966 match v {
967 Value::Object(m) => {
968 for (k, val) in m {
969 if (k == "hash" || k == "new_hash") && val.is_string() {
970 out.push(val.as_str().unwrap().to_string());
971 } else {
972 collect_hashes(val, out);
973 }
974 }
975 }
976 Value::Array(a) => a.iter().for_each(|x| collect_hashes(x, out)),
977 _ => {}
978 }
979}