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 pub fn authz(&self) -> areev_core::authz::AuthzSet {
89 self.facade.authz()
90 }
91}
92
93pub struct BorrowedSubstrate<'a> {
96 facade: &'a AreevFacade,
97}
98
99impl<'a> BorrowedSubstrate<'a> {
100 pub fn new(facade: &'a AreevFacade) -> Self {
101 BorrowedSubstrate { facade }
102 }
103}
104
105impl AreevSubstrate {
106 fn facade_ref(&self) -> &AreevFacade {
107 &self.facade
108 }
109
110 pub fn facade(&self) -> &AreevFacade {
113 &self.facade
114 }
115}
116
117impl BorrowedSubstrate<'_> {
118 fn facade_ref(&self) -> &AreevFacade {
119 self.facade
120 }
121}
122
123macro_rules! impl_substrate {
126 ($ty:ty) => {
127 impl SubstrateRead for $ty {
128 fn capabilities(&self) -> Capabilities {
129 caps(self.facade_ref())
130 }
131 fn grains_of_type(
132 &self,
133 grain_type: &str,
134 namespace: Option<&str>,
135 opts: ReadOpts,
136 ) -> WResult<Vec<GrainRecord>> {
137 grains_of_type(self.facade_ref(), grain_type, namespace, opts)
138 }
139 fn grain(&self, hash: &str) -> WResult<Option<GrainRecord>> {
140 grain(self.facade_ref(), hash)
141 }
142 fn validate_plan(&self, workflow: &Value) -> WResult<()> {
143 validate_plan(workflow)
144 }
145 fn tool_evalset(&self, tool: &str) -> WResult<Option<String>> {
146 tool_evalset(self.facade_ref(), tool)
147 }
148 fn embed(&self, text: &str) -> WResult<Option<Vec<f32>>> {
149 self.facade_ref().with_store(|m| m.embed_text(text)).map_err(we)
150 }
151 fn address_of(&self, spec: &GrainSpec) -> WResult<Option<String>> {
152 address_of(spec)
153 }
154 fn plan_replay(&self, incumbent: &str, candidate: &Value) -> WResult<Option<Value>> {
155 plan_replay(self.facade_ref(), incumbent, candidate)
156 }
157 fn heads(&self, namespace: Option<&str>) -> WResult<Vec<HeadGroup>> {
158 heads(self.facade_ref(), namespace)
159 }
160 fn telemetry(&self, namespace: Option<&str>) -> WResult<Option<TelemetryView>> {
161 telemetry(self.facade_ref(), namespace)
162 }
163 }
164
165 impl OmsSubstrate for $ty {
166 fn put_grain(&mut self, spec: &GrainSpec) -> WResult<String> {
167 put_grain(self.facade_ref(), spec)
168 }
169 fn supersede(
170 &mut self,
171 target_hash: &str,
172 spec: &GrainSpec,
173 _j: &str,
174 ) -> WResult<String> {
175 supersede_op(self.facade_ref(), target_hash, spec)
176 }
177 fn retract(&mut self, hash: &str, _reason: &str) -> WResult<()> {
178 retract_op(self.facade_ref(), hash)
179 }
180 fn execute_cal(&mut self, cal: &str) -> WResult<Vec<Value>> {
181 execute_cal(self.facade_ref(), cal)
182 }
183 fn validate_cal(&self, cal: &str) -> WResult<()> {
184 validate_cal(cal)
185 }
186 fn definition_inverse(&self, statement: &str) -> WResult<Option<String>> {
187 definition_inverse(self.facade_ref(), statement)
188 }
189 fn load_state(&self) -> WResult<Value> {
190 load_state(self.facade_ref())
191 }
192 fn store_state(&mut self, state: &Value) -> WResult<()> {
193 store_state(self.facade_ref(), state)
194 }
195 fn put_blob(&mut self, bytes: &[u8]) -> WResult<String> {
196 put_blob_op(self.facade_ref(), bytes)
197 }
198 fn get_blob(&mut self, address: &str) -> WResult<Vec<u8>> {
199 get_blob_op(self.facade_ref(), address)
200 }
201 }
202 };
203}
204
205impl_substrate!(AreevSubstrate);
206impl_substrate!(BorrowedSubstrate<'_>);
207
208fn recent_ns(f: &AreevFacade, ns: &str, gt: GrainType) -> WResult<Vec<DeserializedGrain>> {
211 require_read(f, ns)?;
215 f.with_store(|m| m.recent(ns, Some(gt), MAX_SCAN))
216 .map_err(we)
217}
218
219fn all_grains(f: &AreevFacade, gt: GrainType) -> WResult<Vec<DeserializedGrain>> {
221 let ops = f.with_store(|m| m.changes_since(0, MAX_SCAN)).map_err(we)?;
222 let mut seen = BTreeSet::new();
223 let mut out = Vec::new();
224 for op in ops {
225 if op.op == OP_FORGET || !seen.insert(op.hash.to_hex()) {
226 continue;
227 }
228 if let Ok(g) = f.with_store(|m| m.get(&op.hash)) {
229 if g.grain_type == gt && may_read(f, &grain_namespace(&g)) {
230 out.push(g);
231 }
232 }
233 }
234 Ok(out)
235}
236
237fn read_user_type(
238 f: &AreevFacade,
239 gt: GrainType,
240 namespace: Option<&str>,
241 opts: ReadOpts,
242) -> WResult<Vec<GrainRecord>> {
243 let explicit = namespace.is_some();
244 let raw = match namespace {
245 Some(ns) => recent_ns(f, ns, gt)?,
246 None => all_grains(f, gt)?,
247 };
248 let superseded = superseded_set(&raw);
249 let mut out = Vec::new();
250 for g in raw {
251 let ns = grain_namespace(&g);
252 if !explicit && (ns == LOOP_NS || ns.starts_with("agent:")) {
264 continue;
265 }
266 if opts.live_only && superseded.contains(&g.hash.to_hex()) {
267 continue;
268 }
269 let created = grain_created_ms(&g);
270 if opts.since_ms.is_some_and(|s| created < s) {
271 continue;
272 }
273 out.push(map_user_grain(&g, ns, created));
274 }
275 Ok(out)
276}
277
278fn read_loop(
280 f: &AreevFacade,
281 relation: &str,
282 out_type: &str,
283 opts: ReadOpts,
284) -> WResult<Vec<GrainRecord>> {
285 let facts = recent_ns(f, LOOP_NS, GrainType::Fact)?;
286 let mut out = Vec::new();
287 for fact in facts {
288 if fact.get_str("relation") != Some(relation) {
289 continue;
290 }
291 let Some(payload) = fact.get_str("object") else {
292 continue;
293 };
294 let fields: Map<String, Value> = match serde_json::from_str(payload) {
295 Ok(Value::Object(m)) => m,
296 _ => continue,
297 };
298 let created = grain_created_ms(&fact);
299 if opts.since_ms.is_some_and(|s| created < s) {
300 continue;
301 }
302 out.push(loop_record(
303 &fact.hash.to_hex(),
304 out_type,
305 created,
306 fields,
307 ));
308 }
309 Ok(out)
310}
311
312fn caps(f: &AreevFacade) -> Capabilities {
313 Capabilities {
314 forks: true,
315 telemetry: f.with_store(|m| m.telemetry_mode()) != TelemetryMode::Off,
317 embeddings: f.with_store(|m| m.declared_embedding().is_some()),
318 plans: true,
322 code: true,
323 }
324}
325
326fn validate_plan(workflow: &Value) -> WResult<()> {
331 let fields = workflow
332 .as_object()
333 .ok_or_else(|| werr("a workflow body must be a JSON object"))?;
334 let wf = strict_workflow(fields)?;
335 areev_run_core::plan::PlanGraph::build(&wf)
336 .map(|_| ())
337 .map_err(|e| werr(&format!("proposed plan is not runnable: {e}")))
338}
339
340fn strict_workflow(fields: &Map<String, Value>) -> WResult<Workflow> {
349 let nodes: Vec<String> = match fields.get("nodes") {
350 Some(Value::Array(a)) => a
351 .iter()
352 .map(|v| {
353 v.as_str()
354 .map(str::to_string)
355 .ok_or_else(|| werr("every workflow node must be a string"))
356 })
357 .collect::<WResult<Vec<String>>>()?,
358 _ => return Err(werr("a workflow needs a 'nodes' array")),
359 };
360 let mut wf = Workflow::new(nodes);
361 if let Some(v) = fields.get("edges") {
362 let arr = v
363 .as_array()
364 .ok_or_else(|| werr("workflow 'edges' must be an array"))?;
365 for e in arr {
366 let (Some(src), Some(dst)) = (
367 e.get("src").and_then(Value::as_str),
368 e.get("dst").and_then(Value::as_str),
369 ) else {
370 return Err(werr("every workflow edge needs a 'src' and a 'dst'"));
371 };
372 let cond = match e.get("cond") {
373 None | Some(Value::Null) => None,
374 Some(Value::String(c)) => Some(c.clone()),
375 Some(_) => return Err(werr("an edge 'cond' must be a string")),
376 };
377 let max_cycles = match e.get("max_cycles") {
378 None | Some(Value::Null) => None,
379 Some(v) => Some(
380 v.as_u64()
381 .and_then(|n| u32::try_from(n).ok())
382 .ok_or_else(|| werr("an edge 'max_cycles' must be a small integer"))?,
383 ),
384 };
385 wf.edges.push(WorkflowEdge {
386 src: src.to_string(),
387 dst: dst.to_string(),
388 cond,
389 max_cycles,
390 });
391 }
392 }
393 if let Some(v) = fields.get("bindings") {
394 let obj = v
395 .as_object()
396 .ok_or_else(|| werr("workflow 'bindings' must be an object"))?;
397 for (node, hash) in obj {
398 let h = hash
399 .as_str()
400 .ok_or_else(|| werr("a workflow binding must be a tool hash string"))?;
401 wf.bindings.insert(node.clone(), h.to_string());
402 }
403 }
404 if let Some(v) = fields.get("retries") {
405 let obj = v
406 .as_object()
407 .ok_or_else(|| werr("workflow 'retries' must be an object"))?;
408 for (node, max) in obj {
409 let m = max
410 .as_u64()
411 .and_then(|n| u32::try_from(n).ok())
412 .ok_or_else(|| werr("a workflow retry count must be a small integer"))?;
413 wf.retries.insert(node.clone(), m);
414 }
415 }
416 Ok(wf)
417}
418
419fn tool_evalset(f: &AreevFacade, tool: &str) -> WResult<Option<String>> {
427 let defs = grains_of_type(f, "tool", None, ReadOpts::default())?;
428 Ok(defs
429 .into_iter()
430 .filter(|g| {
431 g.str_field("kind") == Some("definition") && g.tool_name() == Some(tool)
432 })
433 .filter_map(|g| {
434 g.str_field("evalset_hash")
435 .filter(|h| !h.trim().is_empty())
436 .map(str::to_string)
437 })
438 .next())
439}
440
441fn telemetry(f: &AreevFacade, namespace: Option<&str>) -> WResult<Option<TelemetryView>> {
445 if f.with_store(|m| m.telemetry_mode()) == TelemetryMode::Off {
446 return Ok(None);
447 }
448 if let Some(ns) = namespace {
449 require_read(f, ns)?;
450 }
451 let access = f.with_store(|m| m.telemetry_access_stats(namespace)).map_err(we)?;
452 let queries = f.with_store(|m| m.telemetry_query_stats(namespace)).map_err(we)?;
453 let budget = if may_read(f, "*") {
457 f.with_store(|m| m.telemetry_budget_stats()).map_err(we)?
458 } else {
459 Default::default()
460 };
461 Ok(Some(TelemetryView {
462 access: access
463 .into_iter()
464 .filter(|a| may_read(f, &a.ns))
465 .map(|a| GrainAccess { hash: a.hash, recall_count: a.recall_count, last_ms: a.last_ms })
466 .collect(),
467 queries: queries
468 .into_iter()
469 .filter(|q| may_read(f, &q.ns))
470 .map(|q| QueryUsage {
471 sample: q.sample,
472 run_count: q.run_count,
473 empty_count: q.empty_count,
474 sum_results: q.sum_results,
475 last_ms: q.last_ms,
476 })
477 .collect(),
478 budget: BudgetUsage {
479 sample_count: budget.sample_count,
480 overflow_count: budget.overflow_count,
481 },
482 }))
483}
484
485fn grains_of_type(
486 f: &AreevFacade,
487 grain_type: &str,
488 namespace: Option<&str>,
489 opts: ReadOpts,
490) -> WResult<Vec<GrainRecord>> {
491 match grain_type {
492 "recommendation" => read_loop(f, "loop_recommendation", "recommendation", opts),
493 "fact" => read_user_type(f, GrainType::Fact, namespace, opts),
494 "event" => read_user_type(f, GrainType::Event, namespace, opts),
495 "tool" => read_user_type(f, GrainType::Tool, namespace, opts),
496 "observation" if namespace == Some(LOOP_NS) => {
500 read_loop(f, "loop_audit", "observation", opts)
501 }
502 "observation" => read_user_type(f, GrainType::Observation, namespace, opts),
503 "skill" => read_user_type(f, GrainType::Skill, namespace, opts),
504 "goal" => read_user_type(f, GrainType::Goal, namespace, opts),
505 other => Err(WErr::Substrate(format!("unsupported grain type {other:?}"))),
506 }
507}
508
509fn grain(f: &AreevFacade, hash: &str) -> WResult<Option<GrainRecord>> {
510 let h = Hash::from_hex(hash).map_err(we)?;
511 let g = match f.with_store(|m| m.get(&h)) {
512 Ok(g) => g,
513 Err(AreevError::NotFound(_)) => return Ok(None),
514 Err(e) => return Err(we(e)),
515 };
516 let created = grain_created_ms(&g);
517 let superseded_by = f
523 .with_store(|m| m.supersession_map(&[h]))
524 .map_err(we)?
525 .get(&h)
526 .map(|s| s.to_hex());
527 let ns = grain_namespace(&g);
528 if ns == LOOP_NS {
533 if let Some(rel) = g.get_str("relation") {
534 if let Some(out_type) = loop_relation_type(rel) {
535 require_read(f, LOOP_NS)?;
536 if let Some(Value::Object(fields)) = g
537 .get_str("object")
538 .and_then(|p| serde_json::from_str(p).ok())
539 {
540 let mut rec = loop_record(hash, out_type, created, fields);
541 rec.superseded_by = superseded_by;
542 return Ok(Some(rec));
543 }
544 }
545 }
546 }
547 require_read(f, &ns)?;
548 let mut rec = map_user_grain(&g, ns, created);
549 rec.superseded_by = superseded_by;
550 Ok(Some(rec))
551}
552
553fn heads(f: &AreevFacade, namespace: Option<&str>) -> WResult<Vec<HeadGroup>> {
554 let forks = f.with_store(|m| m.open_forks()).map_err(we)?;
555 Ok(forks
556 .into_iter()
557 .filter(|fg| fg.namespace != LOOP_NS)
558 .filter(|fg| may_read(f, &fg.namespace))
559 .filter(|fg| namespace.is_none_or(|ns| fg.namespace == ns))
560 .map(|fg| HeadGroup {
561 entity: format!("{}/{}", fg.subject, fg.relation),
562 heads: fg.heads.iter().map(|h| h.to_hex()).collect(),
563 })
564 .collect())
565}
566
567const MAX_LOOP_BLOB: usize = 4 * 1024 * 1024;
574
575fn put_blob_op(f: &AreevFacade, bytes: &[u8]) -> WResult<String> {
576 f.authz().check(Verb::Write, LOOP_NS).map_err(we)?;
577 if bytes.len() > MAX_LOOP_BLOB {
578 return Err(WErr::Substrate(format!(
579 "blob is {} bytes (loop cap {MAX_LOOP_BLOB})",
580 bytes.len()
581 )));
582 }
583 f.with_store(|m| m.put_blob(bytes)).map_err(we)
584}
585
586fn get_blob_op(f: &AreevFacade, address: &str) -> WResult<Vec<u8>> {
587 require_read(f, LOOP_NS)?;
588 f.with_store(|m| m.get_blob(address)).map_err(we)
589}
590
591fn put_grain(f: &AreevFacade, spec: &GrainSpec) -> WResult<String> {
592 if spec.grain_type == "fact" {
598 if let (Some(subject), Some(relation), Some(object)) = (
599 spec.fields.get("subject").and_then(Value::as_str),
600 spec.fields.get("relation").and_then(Value::as_str),
601 spec.fields.get("object").and_then(Value::as_str),
602 ) {
603 let mut fact = Fact::new(subject, relation, object)
604 .namespace(LOOP_NS)
605 .confidence(1.0);
606 fact.common.created_at = spec_created_ms(spec);
607 for (k, v) in &spec.fields {
608 if !matches!(k.as_str(), "subject" | "relation" | "object") {
609 fact.common.extra_fields.insert(k.clone(), v.clone());
610 }
611 }
612 return f.with_store(|m| m.add(&fact)).map(|h| h.to_hex()).map_err(we);
613 }
614 }
615 let payload = serde_json::to_string(&spec.fields)
616 .map_err(|e| WErr::Substrate(format!("encode grain: {e}")))?;
617 let mut fact = Fact::new(
618 &unique_subject(&payload),
619 loop_relation(&spec.grain_type),
620 &payload,
621 )
622 .namespace(LOOP_NS)
623 .confidence(1.0);
624 fact.common.created_at = spec_created_ms(spec);
625 f.with_store(|m| m.add(&fact))
626 .map(|h| h.to_hex())
627 .map_err(we)
628}
629
630fn address_of(spec: &GrainSpec) -> WResult<Option<String>> {
636 let Some(created) = spec_created_ms(spec) else {
637 return Ok(None);
638 };
639 let payload = serde_json::to_string(&spec.fields)
640 .map_err(|e| WErr::Substrate(format!("encode grain: {e}")))?;
641 let mut fact = if spec.grain_type == "fact" {
642 match (
643 spec.fields.get("subject").and_then(Value::as_str),
644 spec.fields.get("relation").and_then(Value::as_str),
645 spec.fields.get("object").and_then(Value::as_str),
646 ) {
647 (Some(subject), Some(relation), Some(object)) => {
648 let mut fact = Fact::new(subject, relation, object).namespace(LOOP_NS).confidence(1.0);
649 for (k, v) in &spec.fields {
650 if !matches!(k.as_str(), "subject" | "relation" | "object") {
651 fact.common.extra_fields.insert(k.clone(), v.clone());
652 }
653 }
654 fact
655 }
656 _ => Fact::new(&unique_subject(&payload), loop_relation(&spec.grain_type), &payload)
657 .namespace(LOOP_NS)
658 .confidence(1.0),
659 }
660 } else {
661 Fact::new(&unique_subject(&payload), loop_relation(&spec.grain_type), &payload)
662 .namespace(LOOP_NS)
663 .confidence(1.0)
664 };
665 fact.common.created_at = Some(created);
666 let (_, hash) = areev_core::format::serialize::serialize_grain(&fact).map_err(we)?;
667 Ok(Some(hash.to_hex()))
668}
669
670const PLAN_REPLAY_RUNS: usize = 10;
672
673fn plan_replay(f: &AreevFacade, incumbent: &str, candidate: &Value) -> WResult<Option<Value>> {
680 let Some(fields) = candidate.as_object() else {
681 return Err(werr("a candidate plan must be a JSON object"));
682 };
683 let runs = areev_run::shadow::runs_of_plan(f, None, incumbent, PLAN_REPLAY_RUNS)
684 .map_err(|e| WErr::Substrate(e.to_string()))?;
685 if runs.is_empty() {
686 return Ok(None);
687 }
688 let report = areev_run::shadow::shadow_plan_scoped(
689 f,
690 "loop:replay",
691 &runs,
692 &areev_run::PlanCandidate::Body(fields.clone()),
693 )
694 .map_err(|e| WErr::Substrate(e.to_string()))?;
695 serde_json::to_value(report)
696 .map(Some)
697 .map_err(|e| WErr::Substrate(format!("encode replay: {e}")))
698}
699
700fn supersede_op(f: &AreevFacade, target_hash: &str, spec: &GrainSpec) -> WResult<String> {
701 let payload = serde_json::to_string(&spec.fields)
702 .map_err(|e| WErr::Substrate(format!("encode grain: {e}")))?;
703 let old = Hash::from_hex(target_hash).map_err(we)?;
704 let mut fact = Fact::new(
705 &unique_subject(&payload),
706 loop_relation(&spec.grain_type),
707 &payload,
708 )
709 .namespace(LOOP_NS);
710 fact.common.created_at = spec_created_ms(spec);
711 f.with_store(|m| m.supersede(&old, &mut fact))
712 .map(|h| h.to_hex())
713 .map_err(we)
714}
715
716fn spec_created_ms(spec: &GrainSpec) -> Option<i64> {
723 spec.fields
724 .get("created_at_ms")
725 .or_else(|| spec.fields.get("at_ms"))
726 .and_then(Value::as_i64)
727}
728
729fn retract_op(f: &AreevFacade, hash: &str) -> WResult<()> {
730 let h = Hash::from_hex(hash).map_err(we)?;
733 f.with_store(|m| m.forget(&h)).map_err(we)
734}
735
736fn execute_cal(f: &AreevFacade, cal: &str) -> WResult<Vec<Value>> {
737 let mut rows = Vec::new();
742 for line in cal.lines() {
743 let line = line.trim();
744 if line.is_empty() {
745 continue;
746 }
747 let (keyword, rest) = split_keyword(line);
748 match keyword.to_ascii_uppercase().as_str() {
749 "FORGET" => {
750 let h = Hash::from_hex(rest.trim()).map_err(we)?;
751 f.cal_delete(&h, Some("areev-loop apply")).map_err(we)?;
754 }
755 "ADD" => {
756 let (gtype, fields) = parse_type_and_json(rest)?;
757 let h = f.cal_add(>ype, &fields).map_err(we)?;
758 rows.push(serde_json::json!({ "hash": h.to_hex() }));
759 }
760 "SUPERSEDE" => {
761 let (target, after) = rest
762 .split_once(" WITH ")
763 .ok_or_else(|| WErr::CalUnsupported(format!("malformed SUPERSEDE: {line}")))?;
764 let (gtype, fields) = parse_type_and_json(after)?;
765 let old = Hash::from_hex(target.trim()).map_err(we)?;
766 let h = f.cal_supersede(&old, >ype, &fields).map_err(we)?;
767 rows.push(serde_json::json!({ "hash": h.to_hex() }));
768 }
769 _ => {
770 let parsed = parse(line).map_err(|e| WErr::CalUnsupported(e.to_string()))?;
777 if classify(&parsed.statement) == StatementClass::Destructive {
778 return Err(WErr::CalUnsupported(format!(
779 "destructive CAL beyond FORGET <hash> is not executable through the loop substrate: {line}"
780 )));
781 }
782 let ex = CalExecutor::new(CalExecutorConfig::default());
783 let res = ex
784 .execute(line, f)
785 .map_err(|e| WErr::CalUnsupported(e.to_string()))?;
786 let payload = serde_json::to_value(&res.result)
787 .map_err(|e| WErr::Substrate(format!("encode CAL result: {e}")))?;
788 let mut hs = Vec::new();
789 collect_hashes(&payload, &mut hs);
790 rows.extend(hs.into_iter().map(|h| serde_json::json!({ "hash": h })));
791 }
792 }
793 }
794 Ok(rows)
795}
796
797fn definition_inverse(f: &AreevFacade, statement: &str) -> WResult<Option<String>> {
811 let up = statement.trim_start().to_ascii_uppercase();
812 let (kind, after) = if let Some(rest) = up.strip_prefix("DEFINE QUERY") {
813 ("QUERY", &statement.trim_start()[statement.trim_start().len() - rest.len()..])
814 } else if let Some(rest) = up.strip_prefix("DEFINE TEMPLATE") {
815 ("TEMPLATE", &statement.trim_start()[statement.trim_start().len() - rest.len()..])
816 } else {
817 return Ok(None);
818 };
819 let name = after
821 .trim_start()
822 .trim_start_matches('"')
823 .split(['"', '(', ' ', '\t'])
824 .next()
825 .unwrap_or("")
826 .trim()
827 .to_string();
828 if name.is_empty() {
829 return Ok(None);
830 }
831 if kind == "QUERY" {
832 match f.get_query(&name) {
833 Some(e) if e.builtin => Ok(None),
835 Some(e) => {
836 let params = if e.params.is_empty() {
837 String::new()
838 } else {
839 let list: Vec<String> =
840 e.params.iter().map(|p| format!("${}", p.name)).collect();
841 format!("({})", list.join(", "))
842 };
843 Ok(Some(format!(
844 "DEFINE QUERY \"{name}\"{params} DESCRIPTION \"{}\" AS {{ {} }}",
845 e.description.replace('"', "'"),
846 e.body
847 )))
848 }
849 None => Ok(Some(format!("DROP QUERY \"{name}\""))),
850 }
851 } else {
852 match f.get_template(&name) {
853 Some(t) if t.builtin => Ok(None),
854 Some(t) => Ok(Some(format!(
855 "DEFINE TEMPLATE \"{name}\" DESCRIPTION \"{}\" AS {{ {} }}",
856 t.description.replace('"', "'"),
857 t.source
858 ))),
859 None => Ok(Some(format!("DROP TEMPLATE \"{name}\""))),
860 }
861 }
862}
863
864fn validate_cal(cal: &str) -> WResult<()> {
865 for line in cal.lines() {
866 let line = line.trim();
867 if line.is_empty() {
868 continue;
869 }
870 let (keyword, rest) = split_keyword(line);
871 match keyword.to_ascii_uppercase().as_str() {
872 "ADD" | "SUPERSEDE" => {}
873 "FORGET" => {
876 Hash::from_hex(rest.trim()).map_err(|_| {
877 WErr::CalUnsupported(format!(
878 "loop FORGET takes a single grain hash: {line}"
879 ))
880 })?;
881 }
882 _ => {
883 let parsed = parse(line).map_err(|e| WErr::CalUnsupported(e.to_string()))?;
884 if classify(&parsed.statement) == StatementClass::Destructive {
888 return Err(WErr::CalUnsupported(format!(
889 "destructive CAL beyond FORGET <hash> is not executable through the loop substrate: {line}"
890 )));
891 }
892 }
893 }
894 }
895 Ok(())
896}
897
898pub fn loop_state_of(m: &mut areev_store::Areev) -> WResult<Value> {
904 let head = m.latest(LOOP_NS, STATE_SUBJECT, STATE_RELATION).map_err(we)?;
905 match head.as_ref().and_then(|g| g.get_str("object")) {
906 Some(json) => serde_json::from_str(json)
907 .map_err(|e| WErr::Substrate(format!("decode loop state: {e}"))),
908 None => Ok(Value::Null),
909 }
910}
911
912fn load_state(f: &AreevFacade) -> WResult<Value> {
913 let head = f
914 .with_store(|m| m.latest(LOOP_NS, STATE_SUBJECT, STATE_RELATION))
915 .map_err(we)?;
916 match head.as_ref().and_then(|g| g.get_str("object")) {
917 Some(json) => serde_json::from_str(json)
918 .map_err(|e| WErr::Substrate(format!("decode loop state: {e}"))),
919 None => Ok(Value::Null),
920 }
921}
922
923fn store_state(f: &AreevFacade, state: &Value) -> WResult<()> {
937 let json = serde_json::to_string(state)
938 .map_err(|e| WErr::Substrate(format!("encode loop state: {e}")))?;
939 let existing = f
940 .with_store(|m| m.latest(LOOP_NS, STATE_SUBJECT, STATE_RELATION))
941 .map_err(we)?;
942 f.with_store(|m| {
943 let mut fact = Fact::new(STATE_SUBJECT, STATE_RELATION, &json).namespace(LOOP_NS);
944 match &existing {
945 Some(g) => m.supersede(&g.hash, &mut fact).map(|_| ()),
946 None => m.add(&fact).map(|_| ()),
947 }
948 })
949 .map_err(we)
950}
951
952fn loop_relation(grain_type: &str) -> &'static str {
955 match grain_type {
956 "recommendation" => "loop_recommendation",
957 _ => "loop_audit", }
959}
960
961fn loop_relation_type(relation: &str) -> Option<&'static str> {
962 match relation {
963 "loop_recommendation" => Some("recommendation"),
964 "loop_audit" => Some("observation"),
965 _ => None,
966 }
967}
968
969fn unique_subject(payload: &str) -> String {
972 use std::hash::{Hash as _, Hasher};
973 let mut h = std::collections::hash_map::DefaultHasher::new();
974 payload.hash(&mut h);
975 format!("w{:016x}", h.finish())
976}
977
978fn grain_namespace(g: &DeserializedGrain) -> String {
979 g.get_str("namespace").unwrap_or("").to_string()
980}
981
982fn grain_created_ms(g: &DeserializedGrain) -> i64 {
983 g.get_i64("created_at")
984 .unwrap_or_else(|| g.header.created_at_sec as i64 * 1000)
985}
986
987fn map_user_grain(g: &DeserializedGrain, namespace: String, created_ms: i64) -> GrainRecord {
988 GrainRecord {
989 hash: g.hash.to_hex(),
990 grain_type: g.grain_type.as_str().to_string(),
991 namespace,
992 created_at_ms: created_ms,
993 valid_to_ms: g.get_i64("valid_to"),
994 superseded_by: None,
995 fields: g.fields.clone().into_iter().collect(),
996 }
997}
998
999fn loop_record(
1000 hash: &str,
1001 grain_type: &str,
1002 created_ms: i64,
1003 fields: Map<String, Value>,
1004) -> GrainRecord {
1005 let valid_to_ms = fields.get("valid_to_ms").and_then(Value::as_i64);
1006 GrainRecord {
1007 hash: hash.to_string(),
1008 grain_type: grain_type.to_string(),
1009 namespace: LOOP_NS.to_string(),
1010 created_at_ms: created_ms,
1011 valid_to_ms,
1012 superseded_by: None,
1013 fields,
1014 }
1015}
1016
1017fn superseded_set(grains: &[DeserializedGrain]) -> BTreeSet<String> {
1019 let mut set = BTreeSet::new();
1020 for g in grains {
1021 for parent in derived_parents(g) {
1022 set.insert(parent);
1023 }
1024 }
1025 set
1026}
1027
1028fn derived_parents(g: &DeserializedGrain) -> Vec<String> {
1029 match g.fields.get("derived_from") {
1030 Some(Value::String(s)) => vec![s.clone()],
1031 Some(Value::Array(a)) => a
1032 .iter()
1033 .filter_map(|v| v.as_str().map(str::to_string))
1034 .collect(),
1035 _ => Vec::new(),
1036 }
1037}
1038
1039fn split_keyword(line: &str) -> (&str, &str) {
1040 match line.split_once(char::is_whitespace) {
1041 Some((k, rest)) => (k, rest.trim_start()),
1042 None => (line, ""),
1043 }
1044}
1045
1046fn parse_type_and_json(s: &str) -> WResult<(String, Map<String, Value>)> {
1048 let brace = s
1049 .find('{')
1050 .ok_or_else(|| WErr::CalUnsupported(format!("missing JSON object in {s:?}")))?;
1051 let gtype = s[..brace].trim().to_string();
1052 if gtype.is_empty() {
1053 return Err(WErr::CalUnsupported(format!("missing grain type in {s:?}")));
1054 }
1055 let value: Value = serde_json::from_str(s[brace..].trim())
1056 .map_err(|e| WErr::CalUnsupported(format!("bad JSON in {s:?}: {e}")))?;
1057 match value {
1058 Value::Object(m) => Ok((gtype, m)),
1059 _ => Err(WErr::CalUnsupported(format!("JSON not an object in {s:?}"))),
1060 }
1061}
1062
1063fn collect_hashes(v: &Value, out: &mut Vec<String>) {
1064 match v {
1065 Value::Object(m) => {
1066 for (k, val) in m {
1067 if (k == "hash" || k == "new_hash") && val.is_string() {
1068 out.push(val.as_str().unwrap().to_string());
1069 } else {
1070 collect_hashes(val, out);
1071 }
1072 }
1073 }
1074 Value::Array(a) => a.iter().for_each(|x| collect_hashes(x, out)),
1075 _ => {}
1076 }
1077}