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 embed(&self, text: &str) -> WResult<Option<Vec<f32>>> {
144 self.facade_ref().with_store(|m| m.embed_text(text)).map_err(we)
145 }
146 fn address_of(&self, spec: &GrainSpec) -> WResult<Option<String>> {
147 address_of(spec)
148 }
149 fn plan_replay(&self, incumbent: &str, candidate: &Value) -> WResult<Option<Value>> {
150 plan_replay(self.facade_ref(), incumbent, candidate)
151 }
152 fn heads(&self, namespace: Option<&str>) -> WResult<Vec<HeadGroup>> {
153 heads(self.facade_ref(), namespace)
154 }
155 fn telemetry(&self, namespace: Option<&str>) -> WResult<Option<TelemetryView>> {
156 telemetry(self.facade_ref(), namespace)
157 }
158 }
159
160 impl OmsSubstrate for $ty {
161 fn put_grain(&mut self, spec: &GrainSpec) -> WResult<String> {
162 put_grain(self.facade_ref(), spec)
163 }
164 fn supersede(
165 &mut self,
166 target_hash: &str,
167 spec: &GrainSpec,
168 _j: &str,
169 ) -> WResult<String> {
170 supersede_op(self.facade_ref(), target_hash, spec)
171 }
172 fn retract(&mut self, hash: &str, _reason: &str) -> WResult<()> {
173 retract_op(self.facade_ref(), hash)
174 }
175 fn execute_cal(&mut self, cal: &str) -> WResult<Vec<Value>> {
176 execute_cal(self.facade_ref(), cal)
177 }
178 fn validate_cal(&self, cal: &str) -> WResult<()> {
179 validate_cal(cal)
180 }
181 fn definition_inverse(&self, statement: &str) -> WResult<Option<String>> {
182 definition_inverse(self.facade_ref(), statement)
183 }
184 fn load_state(&self) -> WResult<Value> {
185 load_state(self.facade_ref())
186 }
187 fn store_state(&mut self, state: &Value) -> WResult<()> {
188 store_state(self.facade_ref(), state)
189 }
190 fn put_blob(&mut self, bytes: &[u8]) -> WResult<String> {
191 put_blob_op(self.facade_ref(), bytes)
192 }
193 fn get_blob(&mut self, address: &str) -> WResult<Vec<u8>> {
194 get_blob_op(self.facade_ref(), address)
195 }
196 }
197 };
198}
199
200impl_substrate!(AreevSubstrate);
201impl_substrate!(BorrowedSubstrate<'_>);
202
203fn recent_ns(f: &AreevFacade, ns: &str, gt: GrainType) -> WResult<Vec<DeserializedGrain>> {
206 require_read(f, ns)?;
210 f.with_store(|m| m.recent(ns, Some(gt), MAX_SCAN))
211 .map_err(we)
212}
213
214fn all_grains(f: &AreevFacade, gt: GrainType) -> WResult<Vec<DeserializedGrain>> {
216 let ops = f.with_store(|m| m.changes_since(0, MAX_SCAN)).map_err(we)?;
217 let mut seen = BTreeSet::new();
218 let mut out = Vec::new();
219 for op in ops {
220 if op.op == OP_FORGET || !seen.insert(op.hash.to_hex()) {
221 continue;
222 }
223 if let Ok(g) = f.with_store(|m| m.get(&op.hash)) {
224 if g.grain_type == gt && may_read(f, &grain_namespace(&g)) {
225 out.push(g);
226 }
227 }
228 }
229 Ok(out)
230}
231
232fn read_user_type(
233 f: &AreevFacade,
234 gt: GrainType,
235 namespace: Option<&str>,
236 opts: ReadOpts,
237) -> WResult<Vec<GrainRecord>> {
238 let explicit = namespace.is_some();
239 let raw = match namespace {
240 Some(ns) => recent_ns(f, ns, gt)?,
241 None => all_grains(f, gt)?,
242 };
243 let superseded = superseded_set(&raw);
244 let mut out = Vec::new();
245 for g in raw {
246 let ns = grain_namespace(&g);
247 if !explicit && (ns == LOOP_NS || ns.starts_with("agent:")) {
259 continue;
260 }
261 if opts.live_only && superseded.contains(&g.hash.to_hex()) {
262 continue;
263 }
264 let created = grain_created_ms(&g);
265 if opts.since_ms.is_some_and(|s| created < s) {
266 continue;
267 }
268 out.push(map_user_grain(&g, ns, created));
269 }
270 Ok(out)
271}
272
273fn read_loop(
275 f: &AreevFacade,
276 relation: &str,
277 out_type: &str,
278 opts: ReadOpts,
279) -> WResult<Vec<GrainRecord>> {
280 let facts = recent_ns(f, LOOP_NS, GrainType::Fact)?;
281 let mut out = Vec::new();
282 for fact in facts {
283 if fact.get_str("relation") != Some(relation) {
284 continue;
285 }
286 let Some(payload) = fact.get_str("object") else {
287 continue;
288 };
289 let fields: Map<String, Value> = match serde_json::from_str(payload) {
290 Ok(Value::Object(m)) => m,
291 _ => continue,
292 };
293 let created = grain_created_ms(&fact);
294 if opts.since_ms.is_some_and(|s| created < s) {
295 continue;
296 }
297 out.push(loop_record(
298 &fact.hash.to_hex(),
299 out_type,
300 created,
301 fields,
302 ));
303 }
304 Ok(out)
305}
306
307fn caps(f: &AreevFacade) -> Capabilities {
308 Capabilities {
309 forks: true,
310 telemetry: f.with_store(|m| m.telemetry_mode()) != TelemetryMode::Off,
312 embeddings: f.with_store(|m| m.declared_embedding().is_some()),
313 plans: true,
317 code: true,
318 }
319}
320
321fn validate_plan(workflow: &Value) -> WResult<()> {
326 let fields = workflow
327 .as_object()
328 .ok_or_else(|| werr("a workflow body must be a JSON object"))?;
329 let wf = strict_workflow(fields)?;
330 areev_run_core::plan::PlanGraph::build(&wf)
331 .map(|_| ())
332 .map_err(|e| werr(&format!("proposed plan is not runnable: {e}")))
333}
334
335fn strict_workflow(fields: &Map<String, Value>) -> WResult<Workflow> {
344 let nodes: Vec<String> = match fields.get("nodes") {
345 Some(Value::Array(a)) => a
346 .iter()
347 .map(|v| {
348 v.as_str()
349 .map(str::to_string)
350 .ok_or_else(|| werr("every workflow node must be a string"))
351 })
352 .collect::<WResult<Vec<String>>>()?,
353 _ => return Err(werr("a workflow needs a 'nodes' array")),
354 };
355 let mut wf = Workflow::new(nodes);
356 if let Some(v) = fields.get("edges") {
357 let arr = v
358 .as_array()
359 .ok_or_else(|| werr("workflow 'edges' must be an array"))?;
360 for e in arr {
361 let (Some(src), Some(dst)) = (
362 e.get("src").and_then(Value::as_str),
363 e.get("dst").and_then(Value::as_str),
364 ) else {
365 return Err(werr("every workflow edge needs a 'src' and a 'dst'"));
366 };
367 let cond = match e.get("cond") {
368 None | Some(Value::Null) => None,
369 Some(Value::String(c)) => Some(c.clone()),
370 Some(_) => return Err(werr("an edge 'cond' must be a string")),
371 };
372 let max_cycles = match e.get("max_cycles") {
373 None | Some(Value::Null) => None,
374 Some(v) => Some(
375 v.as_u64()
376 .and_then(|n| u32::try_from(n).ok())
377 .ok_or_else(|| werr("an edge 'max_cycles' must be a small integer"))?,
378 ),
379 };
380 wf.edges.push(WorkflowEdge {
381 src: src.to_string(),
382 dst: dst.to_string(),
383 cond,
384 max_cycles,
385 });
386 }
387 }
388 if let Some(v) = fields.get("bindings") {
389 let obj = v
390 .as_object()
391 .ok_or_else(|| werr("workflow 'bindings' must be an object"))?;
392 for (node, hash) in obj {
393 let h = hash
394 .as_str()
395 .ok_or_else(|| werr("a workflow binding must be a tool hash string"))?;
396 wf.bindings.insert(node.clone(), h.to_string());
397 }
398 }
399 if let Some(v) = fields.get("retries") {
400 let obj = v
401 .as_object()
402 .ok_or_else(|| werr("workflow 'retries' must be an object"))?;
403 for (node, max) in obj {
404 let m = max
405 .as_u64()
406 .and_then(|n| u32::try_from(n).ok())
407 .ok_or_else(|| werr("a workflow retry count must be a small integer"))?;
408 wf.retries.insert(node.clone(), m);
409 }
410 }
411 Ok(wf)
412}
413
414fn tool_evalset(f: &AreevFacade, tool: &str) -> WResult<Option<String>> {
422 let defs = grains_of_type(f, "tool", None, ReadOpts::default())?;
423 Ok(defs
424 .into_iter()
425 .filter(|g| {
426 g.str_field("kind") == Some("definition") && g.tool_name() == Some(tool)
427 })
428 .filter_map(|g| {
429 g.str_field("evalset_hash")
430 .filter(|h| !h.trim().is_empty())
431 .map(str::to_string)
432 })
433 .next())
434}
435
436fn telemetry(f: &AreevFacade, namespace: Option<&str>) -> WResult<Option<TelemetryView>> {
440 if f.with_store(|m| m.telemetry_mode()) == TelemetryMode::Off {
441 return Ok(None);
442 }
443 if let Some(ns) = namespace {
444 require_read(f, ns)?;
445 }
446 let access = f.with_store(|m| m.telemetry_access_stats(namespace)).map_err(we)?;
447 let queries = f.with_store(|m| m.telemetry_query_stats(namespace)).map_err(we)?;
448 let budget = if may_read(f, "*") {
452 f.with_store(|m| m.telemetry_budget_stats()).map_err(we)?
453 } else {
454 Default::default()
455 };
456 Ok(Some(TelemetryView {
457 access: access
458 .into_iter()
459 .filter(|a| may_read(f, &a.ns))
460 .map(|a| GrainAccess { hash: a.hash, recall_count: a.recall_count, last_ms: a.last_ms })
461 .collect(),
462 queries: queries
463 .into_iter()
464 .filter(|q| may_read(f, &q.ns))
465 .map(|q| QueryUsage {
466 sample: q.sample,
467 run_count: q.run_count,
468 empty_count: q.empty_count,
469 sum_results: q.sum_results,
470 last_ms: q.last_ms,
471 })
472 .collect(),
473 budget: BudgetUsage {
474 sample_count: budget.sample_count,
475 overflow_count: budget.overflow_count,
476 },
477 }))
478}
479
480fn grains_of_type(
481 f: &AreevFacade,
482 grain_type: &str,
483 namespace: Option<&str>,
484 opts: ReadOpts,
485) -> WResult<Vec<GrainRecord>> {
486 match grain_type {
487 "recommendation" => read_loop(f, "loop_recommendation", "recommendation", opts),
488 "fact" => read_user_type(f, GrainType::Fact, namespace, opts),
489 "event" => read_user_type(f, GrainType::Event, namespace, opts),
490 "tool" => read_user_type(f, GrainType::Tool, namespace, opts),
491 "observation" if namespace == Some(LOOP_NS) => {
495 read_loop(f, "loop_audit", "observation", opts)
496 }
497 "observation" => read_user_type(f, GrainType::Observation, namespace, opts),
498 "skill" => read_user_type(f, GrainType::Skill, namespace, opts),
499 "goal" => read_user_type(f, GrainType::Goal, namespace, opts),
500 other => Err(WErr::Substrate(format!("unsupported grain type {other:?}"))),
501 }
502}
503
504fn grain(f: &AreevFacade, hash: &str) -> WResult<Option<GrainRecord>> {
505 let h = Hash::from_hex(hash).map_err(we)?;
506 let g = match f.with_store(|m| m.get(&h)) {
507 Ok(g) => g,
508 Err(AreevError::NotFound(_)) => return Ok(None),
509 Err(e) => return Err(we(e)),
510 };
511 let created = grain_created_ms(&g);
512 let superseded_by = f
518 .with_store(|m| m.supersession_map(&[h]))
519 .map_err(we)?
520 .get(&h)
521 .map(|s| s.to_hex());
522 let ns = grain_namespace(&g);
523 if ns == LOOP_NS {
528 if let Some(rel) = g.get_str("relation") {
529 if let Some(out_type) = loop_relation_type(rel) {
530 require_read(f, LOOP_NS)?;
531 if let Some(Value::Object(fields)) = g
532 .get_str("object")
533 .and_then(|p| serde_json::from_str(p).ok())
534 {
535 let mut rec = loop_record(hash, out_type, created, fields);
536 rec.superseded_by = superseded_by;
537 return Ok(Some(rec));
538 }
539 }
540 }
541 }
542 require_read(f, &ns)?;
543 let mut rec = map_user_grain(&g, ns, created);
544 rec.superseded_by = superseded_by;
545 Ok(Some(rec))
546}
547
548fn heads(f: &AreevFacade, namespace: Option<&str>) -> WResult<Vec<HeadGroup>> {
549 let forks = f.with_store(|m| m.open_forks()).map_err(we)?;
550 Ok(forks
551 .into_iter()
552 .filter(|fg| fg.namespace != LOOP_NS)
553 .filter(|fg| may_read(f, &fg.namespace))
554 .filter(|fg| namespace.is_none_or(|ns| fg.namespace == ns))
555 .map(|fg| HeadGroup {
556 entity: format!("{}/{}", fg.subject, fg.relation),
557 heads: fg.heads.iter().map(|h| h.to_hex()).collect(),
558 })
559 .collect())
560}
561
562const MAX_LOOP_BLOB: usize = 4 * 1024 * 1024;
569
570fn put_blob_op(f: &AreevFacade, bytes: &[u8]) -> WResult<String> {
571 f.authz().check(Verb::Write, LOOP_NS).map_err(we)?;
572 if bytes.len() > MAX_LOOP_BLOB {
573 return Err(WErr::Substrate(format!(
574 "blob is {} bytes (loop cap {MAX_LOOP_BLOB})",
575 bytes.len()
576 )));
577 }
578 f.with_store(|m| m.put_blob(bytes)).map_err(we)
579}
580
581fn get_blob_op(f: &AreevFacade, address: &str) -> WResult<Vec<u8>> {
582 require_read(f, LOOP_NS)?;
583 f.with_store(|m| m.get_blob(address)).map_err(we)
584}
585
586fn put_grain(f: &AreevFacade, spec: &GrainSpec) -> WResult<String> {
587 if spec.grain_type == "fact" {
593 if let (Some(subject), Some(relation), Some(object)) = (
594 spec.fields.get("subject").and_then(Value::as_str),
595 spec.fields.get("relation").and_then(Value::as_str),
596 spec.fields.get("object").and_then(Value::as_str),
597 ) {
598 let mut fact = Fact::new(subject, relation, object)
599 .namespace(LOOP_NS)
600 .confidence(1.0);
601 fact.common.created_at = spec_created_ms(spec);
602 for (k, v) in &spec.fields {
603 if !matches!(k.as_str(), "subject" | "relation" | "object") {
604 fact.common.extra_fields.insert(k.clone(), v.clone());
605 }
606 }
607 return f.with_store(|m| m.add(&fact)).map(|h| h.to_hex()).map_err(we);
608 }
609 }
610 let payload = serde_json::to_string(&spec.fields)
611 .map_err(|e| WErr::Substrate(format!("encode grain: {e}")))?;
612 let mut fact = Fact::new(
613 &unique_subject(&payload),
614 loop_relation(&spec.grain_type),
615 &payload,
616 )
617 .namespace(LOOP_NS)
618 .confidence(1.0);
619 fact.common.created_at = spec_created_ms(spec);
620 f.with_store(|m| m.add(&fact))
621 .map(|h| h.to_hex())
622 .map_err(we)
623}
624
625fn address_of(spec: &GrainSpec) -> WResult<Option<String>> {
631 let Some(created) = spec_created_ms(spec) else {
632 return Ok(None);
633 };
634 let payload = serde_json::to_string(&spec.fields)
635 .map_err(|e| WErr::Substrate(format!("encode grain: {e}")))?;
636 let mut fact = if spec.grain_type == "fact" {
637 match (
638 spec.fields.get("subject").and_then(Value::as_str),
639 spec.fields.get("relation").and_then(Value::as_str),
640 spec.fields.get("object").and_then(Value::as_str),
641 ) {
642 (Some(subject), Some(relation), Some(object)) => {
643 let mut fact = Fact::new(subject, relation, object).namespace(LOOP_NS).confidence(1.0);
644 for (k, v) in &spec.fields {
645 if !matches!(k.as_str(), "subject" | "relation" | "object") {
646 fact.common.extra_fields.insert(k.clone(), v.clone());
647 }
648 }
649 fact
650 }
651 _ => Fact::new(&unique_subject(&payload), loop_relation(&spec.grain_type), &payload)
652 .namespace(LOOP_NS)
653 .confidence(1.0),
654 }
655 } else {
656 Fact::new(&unique_subject(&payload), loop_relation(&spec.grain_type), &payload)
657 .namespace(LOOP_NS)
658 .confidence(1.0)
659 };
660 fact.common.created_at = Some(created);
661 let (_, hash) = areev_core::format::serialize::serialize_grain(&fact).map_err(we)?;
662 Ok(Some(hash.to_hex()))
663}
664
665const PLAN_REPLAY_RUNS: usize = 10;
667
668fn plan_replay(f: &AreevFacade, incumbent: &str, candidate: &Value) -> WResult<Option<Value>> {
675 let Some(fields) = candidate.as_object() else {
676 return Err(werr("a candidate plan must be a JSON object"));
677 };
678 let runs = areev_run::shadow::runs_of_plan(f, None, incumbent, PLAN_REPLAY_RUNS)
679 .map_err(|e| WErr::Substrate(e.to_string()))?;
680 if runs.is_empty() {
681 return Ok(None);
682 }
683 let report = areev_run::shadow::shadow_plan_scoped(
684 f,
685 "loop:replay",
686 &runs,
687 &areev_run::PlanCandidate::Body(fields.clone()),
688 )
689 .map_err(|e| WErr::Substrate(e.to_string()))?;
690 serde_json::to_value(report)
691 .map(Some)
692 .map_err(|e| WErr::Substrate(format!("encode replay: {e}")))
693}
694
695fn supersede_op(f: &AreevFacade, target_hash: &str, spec: &GrainSpec) -> WResult<String> {
696 let payload = serde_json::to_string(&spec.fields)
697 .map_err(|e| WErr::Substrate(format!("encode grain: {e}")))?;
698 let old = Hash::from_hex(target_hash).map_err(we)?;
699 let mut fact = Fact::new(
700 &unique_subject(&payload),
701 loop_relation(&spec.grain_type),
702 &payload,
703 )
704 .namespace(LOOP_NS);
705 fact.common.created_at = spec_created_ms(spec);
706 f.with_store(|m| m.supersede(&old, &mut fact))
707 .map(|h| h.to_hex())
708 .map_err(we)
709}
710
711fn spec_created_ms(spec: &GrainSpec) -> Option<i64> {
718 spec.fields
719 .get("created_at_ms")
720 .or_else(|| spec.fields.get("at_ms"))
721 .and_then(Value::as_i64)
722}
723
724fn retract_op(f: &AreevFacade, hash: &str) -> WResult<()> {
725 let h = Hash::from_hex(hash).map_err(we)?;
728 f.with_store(|m| m.forget(&h)).map_err(we)
729}
730
731fn execute_cal(f: &AreevFacade, cal: &str) -> WResult<Vec<Value>> {
732 let mut rows = Vec::new();
737 for line in cal.lines() {
738 let line = line.trim();
739 if line.is_empty() {
740 continue;
741 }
742 let (keyword, rest) = split_keyword(line);
743 match keyword.to_ascii_uppercase().as_str() {
744 "FORGET" => {
745 let h = Hash::from_hex(rest.trim()).map_err(we)?;
746 f.cal_delete(&h, Some("areev-loop apply")).map_err(we)?;
749 }
750 "ADD" => {
751 let (gtype, fields) = parse_type_and_json(rest)?;
752 let h = f.cal_add(>ype, &fields).map_err(we)?;
753 rows.push(serde_json::json!({ "hash": h.to_hex() }));
754 }
755 "SUPERSEDE" => {
756 let (target, after) = rest
757 .split_once(" WITH ")
758 .ok_or_else(|| WErr::CalUnsupported(format!("malformed SUPERSEDE: {line}")))?;
759 let (gtype, fields) = parse_type_and_json(after)?;
760 let old = Hash::from_hex(target.trim()).map_err(we)?;
761 let h = f.cal_supersede(&old, >ype, &fields).map_err(we)?;
762 rows.push(serde_json::json!({ "hash": h.to_hex() }));
763 }
764 _ => {
765 let parsed = parse(line).map_err(|e| WErr::CalUnsupported(e.to_string()))?;
772 if classify(&parsed.statement) == StatementClass::Destructive {
773 return Err(WErr::CalUnsupported(format!(
774 "destructive CAL beyond FORGET <hash> is not executable through the loop substrate: {line}"
775 )));
776 }
777 let ex = CalExecutor::new(CalExecutorConfig::default());
778 let res = ex
779 .execute(line, f)
780 .map_err(|e| WErr::CalUnsupported(e.to_string()))?;
781 let payload = serde_json::to_value(&res.result)
782 .map_err(|e| WErr::Substrate(format!("encode CAL result: {e}")))?;
783 let mut hs = Vec::new();
784 collect_hashes(&payload, &mut hs);
785 rows.extend(hs.into_iter().map(|h| serde_json::json!({ "hash": h })));
786 }
787 }
788 }
789 Ok(rows)
790}
791
792fn definition_inverse(f: &AreevFacade, statement: &str) -> WResult<Option<String>> {
806 let up = statement.trim_start().to_ascii_uppercase();
807 let (kind, after) = if let Some(rest) = up.strip_prefix("DEFINE QUERY") {
808 ("QUERY", &statement.trim_start()[statement.trim_start().len() - rest.len()..])
809 } else if let Some(rest) = up.strip_prefix("DEFINE TEMPLATE") {
810 ("TEMPLATE", &statement.trim_start()[statement.trim_start().len() - rest.len()..])
811 } else {
812 return Ok(None);
813 };
814 let name = after
816 .trim_start()
817 .trim_start_matches('"')
818 .split(['"', '(', ' ', '\t'])
819 .next()
820 .unwrap_or("")
821 .trim()
822 .to_string();
823 if name.is_empty() {
824 return Ok(None);
825 }
826 if kind == "QUERY" {
827 match f.get_query(&name) {
828 Some(e) if e.builtin => Ok(None),
830 Some(e) => {
831 let params = if e.params.is_empty() {
832 String::new()
833 } else {
834 let list: Vec<String> =
835 e.params.iter().map(|p| format!("${}", p.name)).collect();
836 format!("({})", list.join(", "))
837 };
838 Ok(Some(format!(
839 "DEFINE QUERY \"{name}\"{params} DESCRIPTION \"{}\" AS {{ {} }}",
840 e.description.replace('"', "'"),
841 e.body
842 )))
843 }
844 None => Ok(Some(format!("DROP QUERY \"{name}\""))),
845 }
846 } else {
847 match f.get_template(&name) {
848 Some(t) if t.builtin => Ok(None),
849 Some(t) => Ok(Some(format!(
850 "DEFINE TEMPLATE \"{name}\" DESCRIPTION \"{}\" AS {{ {} }}",
851 t.description.replace('"', "'"),
852 t.source
853 ))),
854 None => Ok(Some(format!("DROP TEMPLATE \"{name}\""))),
855 }
856 }
857}
858
859fn validate_cal(cal: &str) -> WResult<()> {
860 for line in cal.lines() {
861 let line = line.trim();
862 if line.is_empty() {
863 continue;
864 }
865 let (keyword, rest) = split_keyword(line);
866 match keyword.to_ascii_uppercase().as_str() {
867 "ADD" | "SUPERSEDE" => {}
868 "FORGET" => {
871 Hash::from_hex(rest.trim()).map_err(|_| {
872 WErr::CalUnsupported(format!(
873 "loop FORGET takes a single grain hash: {line}"
874 ))
875 })?;
876 }
877 _ => {
878 let parsed = parse(line).map_err(|e| WErr::CalUnsupported(e.to_string()))?;
879 if classify(&parsed.statement) == StatementClass::Destructive {
883 return Err(WErr::CalUnsupported(format!(
884 "destructive CAL beyond FORGET <hash> is not executable through the loop substrate: {line}"
885 )));
886 }
887 }
888 }
889 }
890 Ok(())
891}
892
893fn load_state(f: &AreevFacade) -> WResult<Value> {
894 let head = f
895 .with_store(|m| m.latest(LOOP_NS, STATE_SUBJECT, STATE_RELATION))
896 .map_err(we)?;
897 match head.as_ref().and_then(|g| g.get_str("object")) {
898 Some(json) => serde_json::from_str(json)
899 .map_err(|e| WErr::Substrate(format!("decode loop state: {e}"))),
900 None => Ok(Value::Null),
901 }
902}
903
904fn store_state(f: &AreevFacade, state: &Value) -> WResult<()> {
918 let json = serde_json::to_string(state)
919 .map_err(|e| WErr::Substrate(format!("encode loop state: {e}")))?;
920 let existing = f
921 .with_store(|m| m.latest(LOOP_NS, STATE_SUBJECT, STATE_RELATION))
922 .map_err(we)?;
923 f.with_store(|m| {
924 let mut fact = Fact::new(STATE_SUBJECT, STATE_RELATION, &json).namespace(LOOP_NS);
925 match &existing {
926 Some(g) => m.supersede(&g.hash, &mut fact).map(|_| ()),
927 None => m.add(&fact).map(|_| ()),
928 }
929 })
930 .map_err(we)
931}
932
933fn loop_relation(grain_type: &str) -> &'static str {
936 match grain_type {
937 "recommendation" => "loop_recommendation",
938 _ => "loop_audit", }
940}
941
942fn loop_relation_type(relation: &str) -> Option<&'static str> {
943 match relation {
944 "loop_recommendation" => Some("recommendation"),
945 "loop_audit" => Some("observation"),
946 _ => None,
947 }
948}
949
950fn unique_subject(payload: &str) -> String {
953 use std::hash::{Hash as _, Hasher};
954 let mut h = std::collections::hash_map::DefaultHasher::new();
955 payload.hash(&mut h);
956 format!("w{:016x}", h.finish())
957}
958
959fn grain_namespace(g: &DeserializedGrain) -> String {
960 g.get_str("namespace").unwrap_or("").to_string()
961}
962
963fn grain_created_ms(g: &DeserializedGrain) -> i64 {
964 g.get_i64("created_at")
965 .unwrap_or_else(|| g.header.created_at_sec as i64 * 1000)
966}
967
968fn map_user_grain(g: &DeserializedGrain, namespace: String, created_ms: i64) -> GrainRecord {
969 GrainRecord {
970 hash: g.hash.to_hex(),
971 grain_type: g.grain_type.as_str().to_string(),
972 namespace,
973 created_at_ms: created_ms,
974 valid_to_ms: g.get_i64("valid_to"),
975 superseded_by: None,
976 fields: g.fields.clone().into_iter().collect(),
977 }
978}
979
980fn loop_record(
981 hash: &str,
982 grain_type: &str,
983 created_ms: i64,
984 fields: Map<String, Value>,
985) -> GrainRecord {
986 let valid_to_ms = fields.get("valid_to_ms").and_then(Value::as_i64);
987 GrainRecord {
988 hash: hash.to_string(),
989 grain_type: grain_type.to_string(),
990 namespace: LOOP_NS.to_string(),
991 created_at_ms: created_ms,
992 valid_to_ms,
993 superseded_by: None,
994 fields,
995 }
996}
997
998fn superseded_set(grains: &[DeserializedGrain]) -> BTreeSet<String> {
1000 let mut set = BTreeSet::new();
1001 for g in grains {
1002 for parent in derived_parents(g) {
1003 set.insert(parent);
1004 }
1005 }
1006 set
1007}
1008
1009fn derived_parents(g: &DeserializedGrain) -> Vec<String> {
1010 match g.fields.get("derived_from") {
1011 Some(Value::String(s)) => vec![s.clone()],
1012 Some(Value::Array(a)) => a
1013 .iter()
1014 .filter_map(|v| v.as_str().map(str::to_string))
1015 .collect(),
1016 _ => Vec::new(),
1017 }
1018}
1019
1020fn split_keyword(line: &str) -> (&str, &str) {
1021 match line.split_once(char::is_whitespace) {
1022 Some((k, rest)) => (k, rest.trim_start()),
1023 None => (line, ""),
1024 }
1025}
1026
1027fn parse_type_and_json(s: &str) -> WResult<(String, Map<String, Value>)> {
1029 let brace = s
1030 .find('{')
1031 .ok_or_else(|| WErr::CalUnsupported(format!("missing JSON object in {s:?}")))?;
1032 let gtype = s[..brace].trim().to_string();
1033 if gtype.is_empty() {
1034 return Err(WErr::CalUnsupported(format!("missing grain type in {s:?}")));
1035 }
1036 let value: Value = serde_json::from_str(s[brace..].trim())
1037 .map_err(|e| WErr::CalUnsupported(format!("bad JSON in {s:?}: {e}")))?;
1038 match value {
1039 Value::Object(m) => Ok((gtype, m)),
1040 _ => Err(WErr::CalUnsupported(format!("JSON not an object in {s:?}"))),
1041 }
1042}
1043
1044fn collect_hashes(v: &Value, out: &mut Vec<String>) {
1045 match v {
1046 Value::Object(m) => {
1047 for (k, val) in m {
1048 if (k == "hash" || k == "new_hash") && val.is_string() {
1049 out.push(val.as_str().unwrap().to_string());
1050 } else {
1051 collect_hashes(val, out);
1052 }
1053 }
1054 }
1055 Value::Array(a) => a.iter().for_each(|x| collect_hashes(x, out)),
1056 _ => {}
1057 }
1058}