1use std::collections::HashMap;
16use std::path::Path;
17
18use omgbase_format::BlockKind;
19use omgbase_format::text::{normalize_text, normalize_visible_text};
20use omgbase_reconcile::Config;
21use omgbase_search::EmbeddingProvider;
22use omgbase_store::docs_ops::{canonical, retargeted_json};
23use omgbase_store::links::inbound_links_to;
24use omgbase_store::mutate_kernel::effects::{Admission, EffectExpect, Target as EffectTarget};
25use omgbase_store::mutate_kernel::{At, ErrorCode, Parent};
26use omgbase_store::{
27 ApplyOrigin, DocStore, Expect, FsDocStore, MutationError, Plan, PlanContext, PublicEffect,
28 QueryVector, Receipt, Retargeted, Store, sugar,
29};
30use omgbase_sync::{RealFileSystem, RepoRow};
31use rusqlite::{OptionalExtension, params};
32use serde_json::{Map, Value as Json, json};
33
34use crate::arguments::{argument_error, validate_arguments};
35use crate::dates;
36use crate::error::{Result, SurfaceError};
37use crate::fixes;
38use crate::graph::{GraphArgs, graph_neighborhood};
39use crate::history;
40use crate::links;
41use crate::names::VocabularyCache;
42use crate::paths::{reference_keyed, reference_path, root_field, storage_path};
43use crate::query::{QueryOptions, embed_phrases, query};
44use crate::read::{self, Resolution, ResolvedRef};
45use crate::reference::QUERY_SYNTAX;
46use crate::select::{self, SelectRequest};
47use crate::version::{BuildInfo, version_info};
48
49pub const ACTOR: &str = "agent:mcp";
51
52#[derive(Clone, Debug, PartialEq)]
54pub struct ToolSpec {
55 pub name: &'static str,
56 pub description: &'static str,
57 pub input_schema: Json,
59}
60
61#[derive(Clone, Debug, PartialEq)]
64pub struct ToolOutcome {
65 pub body: Json,
66 pub is_error: bool,
67}
68
69enum WriteTarget {
71 Derived,
74 Fixed(Box<dyn DocStore>),
77}
78
79pub struct Surface {
81 store: Store,
82 default_repo: String,
83 provider: Option<Box<dyn EmbeddingProvider>>,
84 provider_name: Option<String>,
87 on_mutation: Option<Box<dyn FnMut()>>,
88 clock: Box<dyn FnMut() -> String>,
89 writes: WriteTarget,
90 config: Config,
91 actor: String,
93 build_info: BuildInfo,
95 preview: bool,
99 last_plan: Option<Plan>,
103 profile: Profile,
106 vocabulary: VocabularyCache,
110}
111
112fn bad_args(msg: impl Into<String>) -> SurfaceError {
115 SurfaceError::filter_invalid(msg, "arguments")
116}
117
118fn arg_str<'a>(args: &'a Json, key: &str) -> Option<&'a str> {
119 args.get(key).and_then(Json::as_str)
120}
121
122fn arg_string(args: &Json, key: &str) -> Result<String> {
123 arg_str(args, key)
124 .map(str::to_owned)
125 .ok_or_else(|| bad_args(format!("`{key}` must be a string")))
126}
127
128fn arg_i64(args: &Json, key: &str) -> Result<Option<i64>> {
129 match args.get(key) {
130 None | Some(Json::Null) => Ok(None),
131 Some(v) => v
132 .as_f64()
133 .filter(|n| n.fract() == 0.0)
134 .map(|n| Some(n as i64))
135 .ok_or_else(|| bad_args(format!("`{key}` must be an integer"))),
136 }
137}
138
139fn arg_usize(args: &Json, key: &str) -> Result<Option<usize>> {
140 Ok(arg_i64(args, key)?.map(|n| usize::try_from(n).unwrap_or(0)))
141}
142
143fn arg_bool(args: &Json, key: &str) -> Result<Option<bool>> {
144 match args.get(key) {
145 None | Some(Json::Null) => Ok(None),
146 Some(Json::Bool(b)) => Ok(Some(*b)),
147 Some(_) => Err(bad_args(format!("`{key}` must be a boolean"))),
148 }
149}
150
151fn arg_strings(args: &Json, key: &str) -> Result<Vec<String>> {
152 let Some(arr) = args.get(key).and_then(Json::as_array) else {
153 return Err(bad_args(format!("`{key}` must be an array of strings")));
154 };
155 arr.iter()
156 .map(|v| {
157 v.as_str()
158 .map(str::to_owned)
159 .ok_or_else(|| bad_args(format!("`{key}` must be an array of strings")))
160 })
161 .collect()
162}
163
164fn arg_object<'a>(args: &'a Json, key: &str) -> Result<Option<&'a Map<String, Json>>> {
165 match args.get(key) {
166 None | Some(Json::Null) => Ok(None),
167 Some(Json::Object(o)) => Ok(Some(o)),
168 Some(_) => Err(bad_args(format!("`{key}` must be an object"))),
169 }
170}
171
172fn hex_field(e: &Map<String, Json>, k: &str) -> Option<String> {
173 e.get(k).and_then(Json::as_str).map(str::to_owned)
174}
175
176fn arg_expect(args: &Json) -> Result<Option<Expect>> {
179 Ok(arg_object(args, "expect")?.map(|e| Expect {
180 content_hash: hex_field(e, "content_hash"),
181 parent_children_hash: hex_field(e, "parent_children_hash"),
182 }))
183}
184
185fn arg_parent_expect(args: &Json) -> Result<Option<Expect>> {
190 Ok(arg_object(args, "expect")?.map(|e| Expect {
191 content_hash: None,
192 parent_children_hash: hex_field(e, "parent_children_hash"),
193 }))
194}
195
196fn resolution_arg(args: &Json, default: Resolution) -> Result<Resolution> {
197 match arg_str(args, "resolution") {
198 None => Ok(default),
199 Some(s) => Resolution::parse(s).ok_or_else(|| {
200 bad_args("`resolution` must be one of skeleton, outline, text, raw, full")
201 }),
202 }
203}
204
205fn merge(mut a: Map<String, Json>, b: Json) -> Json {
207 if let Json::Object(o) = b {
208 for (k, v) in o {
209 a.insert(k, v);
210 }
211 }
212 Json::Object(a)
213}
214
215fn require_some(tool: &str, arg: &str, list: Vec<String>, spec: &ToolSpec) -> Result<Vec<String>> {
232 if list.is_empty() {
233 return Err(argument_error(
234 tool,
235 &format!("`{arg}` must name at least one block"),
236 &spec.input_schema,
237 ));
238 }
239 Ok(list)
240}
241
242fn effect_expect(e: Option<Expect>) -> Option<EffectExpect> {
245 e.map(|e| EffectExpect {
246 content_hash: e.content_hash,
247 parent_children_hash: e.parent_children_hash,
248 revision: None,
249 })
250}
251
252fn placement_target(parent: Parent, doc_id: String) -> EffectTarget {
254 match parent {
255 Parent::Doc => EffectTarget::Document {
256 id: Some(doc_id),
257 path: None,
258 },
259 Parent::Block(id) => EffectTarget::Block { id },
260 Parent::Section { heading } => EffectTarget::Section {
261 heading_id: heading,
262 },
263 }
264}
265
266type SugarOutcome = (Plan, Option<Receipt>);
269
270fn strip_effect_index(e: omgbase_store::Error) -> omgbase_store::Error {
274 match e {
275 omgbase_store::Error::Mutation(mut m) => {
276 m.data.remove("effect_index");
277 omgbase_store::Error::Mutation(m)
278 }
279 other => other,
280 }
281}
282
283fn sugar_json((plan, receipt): &SugarOutcome) -> Json {
285 match receipt {
286 Some(r) => receipt_json(r),
287 None => plan_json(plan),
288 }
289}
290
291fn schema(props: &[(&str, Json)], required: &[&str], repo: bool) -> Json {
292 let mut p = Map::new();
293 for (k, v) in props {
294 p.insert((*k).to_owned(), v.clone());
295 }
296 if repo {
297 p.insert("repo".to_owned(), json!({ "type": "string" }));
298 }
299 json!({ "type": "object", "properties": p, "required": required })
300}
301
302fn s() -> Json {
303 json!({ "type": "string" })
304}
305fn i() -> Json {
306 json!({ "type": "integer" })
307}
308fn b() -> Json {
309 json!({ "type": "boolean" })
310}
311fn strings() -> Json {
312 json!({ "type": "array", "items": { "type": "string" } })
313}
314fn obj() -> Json {
315 json!({ "type": "object" })
316}
317fn nullable_string() -> Json {
318 json!({ "type": ["string", "null"] })
319}
320fn resolution_schema() -> Json {
321 json!({ "type": "string", "enum": ["skeleton", "outline", "text", "raw", "full"] })
322}
323fn at_schema() -> Json {
324 json!({ "oneOf": [
325 { "const": "start" }, { "const": "end" },
326 { "type": "object", "properties": { "before": { "type": "string" } }, "required": ["before"] },
327 { "type": "object", "properties": { "after": { "type": "string" } }, "required": ["after"] }
328 ] })
329}
330fn expect_schema() -> Json {
331 json!({ "type": "object", "properties": { "content_hash": { "type": "string" }, "parent_children_hash": { "type": "string" } } })
332}
333fn effects_schema() -> Json {
338 json!({ "type": "array", "items": { "type": "object" } })
339}
340
341fn parent_expect_schema() -> Json {
342 json!({ "type": "object", "properties": { "parent_children_hash": { "type": "string" } } })
343}
344
345#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
349pub enum Profile {
350 #[default]
352 Full,
353 Minimal,
356}
357
358pub const MINIMAL_TOOLS: &[&str] = &[
361 "query",
362 "query_syntax",
363 "docs_read",
364 "docs_tree",
365 "blocks_resolve",
366 "changes_since",
367 "plan",
368 "execute",
369 "apply",
370 "version",
371];
372
373impl Profile {
374 #[must_use]
376 pub const fn as_str(self) -> &'static str {
377 match self {
378 Self::Full => "full",
379 Self::Minimal => "minimal",
380 }
381 }
382
383 #[must_use]
385 pub fn includes(self, tool: &str) -> bool {
386 match self {
387 Self::Full => true,
388 Self::Minimal => MINIMAL_TOOLS.contains(&tool),
389 }
390 }
391
392 #[must_use]
394 pub fn tools(self) -> Vec<ToolSpec> {
395 catalog()
396 .iter()
397 .filter(|t| self.includes(t.name))
398 .cloned()
399 .collect()
400 }
401}
402
403impl std::str::FromStr for Profile {
404 type Err = String;
405
406 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
408 match s {
409 "full" => Ok(Self::Full),
410 "minimal" => Ok(Self::Minimal),
411 other => Err(format!(
412 "unknown tools profile {} (expected full|minimal)",
413 Json::String(other.to_owned())
414 )),
415 }
416 }
417}
418
419impl std::fmt::Display for Profile {
420 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421 f.write_str(self.as_str())
422 }
423}
424
425#[must_use]
430pub fn tools() -> Vec<ToolSpec> {
431 catalog().to_vec()
432}
433
434pub(crate) fn catalog() -> &'static [ToolSpec] {
436 static CATALOG: std::sync::OnceLock<Vec<ToolSpec>> = std::sync::OnceLock::new();
437 CATALOG.get_or_init(build_catalog)
438}
439
440pub(crate) fn tool_spec(name: &str) -> Option<&'static ToolSpec> {
442 catalog().iter().find(|t| t.name == name)
443}
444
445#[allow(clippy::too_many_lines)]
446fn build_catalog() -> Vec<ToolSpec> {
447 use crate::descriptions as d;
448 let doc = |extra: &[(&str, Json)], required: &[&str]| {
451 let mut props = vec![("_id", s()), ("_path", s())];
452 props.extend(extra.iter().cloned());
453 schema(&props, required, true)
454 };
455 vec![
456 ToolSpec {
457 name: "docs_outline",
458 description: d::DOCS_OUTLINE,
459 input_schema: doc(
460 &[
461 (
462 "resolution",
463 json!({ "type": "string", "enum": ["skeleton", "outline"] }),
464 ),
465 ("depth", i()),
466 ("budget_tokens", i()),
467 ],
468 &[],
469 ),
470 },
471 ToolSpec {
472 name: "docs_read",
473 description: d::DOCS_READ,
474 input_schema: doc(&[("include_ids", b()), ("at", s())], &[]),
475 },
476 ToolSpec {
477 name: "docs_read_many",
478 description: d::DOCS_READ_MANY,
479 input_schema: schema(
480 &[
481 ("docs", strings()),
482 ("include_ids", b()),
483 ("budget_tokens", i()),
484 ],
485 &["docs"],
486 true,
487 ),
488 },
489 ToolSpec {
490 name: "blocks_read",
491 description: d::BLOCKS_READ,
492 input_schema: schema(
493 &[("_id", s()), ("resolution", resolution_schema())],
494 &["_id"],
495 true,
496 ),
497 },
498 ToolSpec {
499 name: "nodes_block_read",
500 description: d::NODES_BLOCK_READ,
501 input_schema: schema(
502 &[("_id", s()), ("resolution", resolution_schema())],
503 &["_id"],
504 true,
505 ),
506 },
507 ToolSpec {
508 name: "blocks_read_many",
509 description: d::BLOCKS_READ_MANY,
510 input_schema: schema(
511 &[
512 ("_ids", strings()),
513 ("resolution", resolution_schema()),
514 ("budget_tokens", i()),
515 ],
516 &["_ids"],
517 true,
518 ),
519 },
520 ToolSpec {
521 name: "docs_tree",
522 description: d::DOCS_TREE,
523 input_schema: schema(
524 &[
525 ("path", s()),
526 ("depth", i()),
527 ("limit", i()),
528 ("cursor", nullable_string()),
529 ("budget_tokens", i()),
530 ],
531 &[],
532 true,
533 ),
534 },
535 ToolSpec {
536 name: "docs_list",
537 description: d::DOCS_LIST,
538 input_schema: schema(
539 &[
540 ("path_glob", s()),
541 ("limit", i()),
542 ("cursor", nullable_string()),
543 ("budget_tokens", i()),
544 ],
545 &[],
546 true,
547 ),
548 },
549 ToolSpec {
550 name: "query_syntax",
551 description: d::QUERY_SYNTAX,
552 input_schema: schema(&[], &[], false),
553 },
554 ToolSpec {
555 name: "query",
556 description: d::QUERY,
557 input_schema: schema(
558 &[
559 ("query", s()),
560 ("limit", i()),
561 ("cursor", nullable_string()),
562 ("bare", b()),
563 ],
564 &["query"],
565 true,
566 ),
567 },
568 ToolSpec {
569 name: "graph",
570 description: d::GRAPH,
571 input_schema: schema(
572 &[
573 ("roots", strings()),
574 ("degrees", i()),
575 (
576 "direction",
577 json!({ "type": "string", "enum": ["in", "out", "both"] }),
578 ),
579 ("predicate", s()),
580 ("select", strings()),
581 ("max_documents", i()),
582 ],
583 &["roots"],
584 true,
585 ),
586 },
587 ToolSpec {
588 name: "blocks_search",
589 description: d::BLOCKS_SEARCH,
590 input_schema: schema(&[("q", s()), ("limit", i())], &["q"], true),
591 },
592 ToolSpec {
593 name: "blocks_resolve",
594 description: d::BLOCKS_RESOLVE,
595 input_schema: schema(&[("query", s()), ("limit", i())], &["query"], true),
596 },
597 ToolSpec {
598 name: "apply",
599 description: d::APPLY,
600 input_schema: schema(
601 &[
602 ("effects", effects_schema()),
603 ("select", s()),
604 ("expect", obj()),
605 ("reason", s()),
606 ],
607 &["effects"],
608 true,
609 ),
610 },
611 ToolSpec {
612 name: "plan",
613 description: d::PLAN,
614 input_schema: schema(
615 &[
616 ("effects", effects_schema()),
617 ("select", s()),
618 ("expect", obj()),
619 ("diffs", b()),
620 ],
621 &["effects"],
622 true,
623 ),
624 },
625 ToolSpec {
626 name: "execute",
627 description: d::EXECUTE,
628 input_schema: schema(
629 &[
630 ("plan", obj()),
631 ("plan_id", s()),
632 ("effects", effects_schema()),
633 ("select", s()),
634 ("expect", obj()),
635 ("reason", s()),
636 ],
637 &[],
638 true,
639 ),
640 },
641 ToolSpec {
642 name: "blocks_insert",
643 description: d::BLOCKS_INSERT,
644 input_schema: schema(
645 &[
646 ("to", s()),
647 ("markdown", s()),
648 ("at", at_schema()),
649 ("expect", parent_expect_schema()),
650 ],
651 &["to", "markdown"],
652 true,
653 ),
654 },
655 ToolSpec {
656 name: "blocks_update",
657 description: d::BLOCKS_UPDATE,
658 input_schema: schema(
659 &[
660 ("_id", s()),
661 ("markdown", s()),
662 ("checked", b()),
663 ("expect", expect_schema()),
664 ],
665 &["_id"],
666 true,
667 ),
668 },
669 ToolSpec {
670 name: "blocks_move",
671 description: d::BLOCKS_MOVE,
672 input_schema: schema(
673 &[
674 ("blocks", strings()),
675 ("to", s()),
676 ("at", at_schema()),
677 ("expect", parent_expect_schema()),
678 ],
679 &["blocks", "to"],
680 true,
681 ),
682 },
683 ToolSpec {
684 name: "blocks_remove",
685 description: d::BLOCKS_REMOVE,
686 input_schema: schema(&[("blocks", strings())], &["blocks"], true),
687 },
688 ToolSpec {
689 name: "blocks_split",
690 description: d::BLOCKS_SPLIT,
691 input_schema: schema(
692 &[
693 ("_id", s()),
694 (
695 "at",
696 json!({ "type": "array", "items": { "type": "integer" } }),
697 ),
698 ],
699 &["_id", "at"],
700 true,
701 ),
702 },
703 ToolSpec {
704 name: "blocks_merge",
705 description: d::BLOCKS_MERGE,
706 input_schema: schema(
707 &[("blocks", strings()), ("separator", s())],
708 &["blocks"],
709 true,
710 ),
711 },
712 ToolSpec {
713 name: "blocks_check",
714 description: d::BLOCKS_CHECK,
715 input_schema: schema(&[("_ids", strings()), ("checked", b())], &["_ids"], true),
716 },
717 ToolSpec {
718 name: "nodes_set",
719 description: d::NODES_SET,
720 input_schema: schema(
721 &[("_id", s()), ("prop", s()), ("value", s())],
722 &["_id", "prop", "value"],
723 true,
724 ),
725 },
726 ToolSpec {
727 name: "blocks_append",
728 description: d::BLOCKS_APPEND,
729 input_schema: schema(&[("_id", s()), ("text", s())], &["_id", "text"], true),
730 },
731 ToolSpec {
732 name: "docs_append",
733 description: d::DOCS_APPEND,
734 input_schema: doc(&[("text", s()), ("section", s())], &["text"]),
735 },
736 ToolSpec {
737 name: "links_retarget",
738 description: d::LINKS_RETARGET,
739 input_schema: schema(
740 &[("from_target", s()), ("to_target", s()), ("path_glob", s())],
741 &["from_target", "to_target"],
742 true,
743 ),
744 },
745 ToolSpec {
746 name: "links_stale",
747 description: d::LINKS_STALE,
748 input_schema: schema(
749 &[("path_glob", s()), ("limit", i()), ("summary", b())],
750 &[],
751 true,
752 ),
753 },
754 ToolSpec {
755 name: "links_repair",
756 description: d::LINKS_REPAIR,
757 input_schema: schema(
758 &[
759 (
760 "repairs",
761 json!({ "type": "array", "items": { "type": "object", "properties": { "from": { "type": "string" }, "to": { "type": "string" } }, "required": ["from", "to"] } }),
762 ),
763 ("from_target", s()),
764 ("to_target", s()),
765 ("path_glob", s()),
766 ],
767 &[],
768 true,
769 ),
770 },
771 ToolSpec {
772 name: "docs_create",
773 description: d::DOCS_CREATE,
774 input_schema: schema(
775 &[("path", s()), ("markdown", s()), ("frontmatter", obj())],
776 &["path", "markdown"],
777 true,
778 ),
779 },
780 ToolSpec {
781 name: "docs_move",
782 description: d::DOCS_MOVE,
783 input_schema: doc(&[("to_path", s()), ("retarget_inbound", b())], &["to_path"]),
784 },
785 ToolSpec {
786 name: "docs_delete",
787 description: d::DOCS_DELETE,
788 input_schema: doc(&[], &[]),
789 },
790 ToolSpec {
791 name: "docs_set_meta",
792 description: d::DOCS_SET_META,
793 input_schema: doc(&[("set", obj()), ("unset", strings())], &[]),
794 },
795 ToolSpec {
796 name: "docs_update",
797 description: d::DOCS_UPDATE,
798 input_schema: doc(&[("content", s()), ("reason", s())], &["content"]),
799 },
800 ToolSpec {
801 name: "observe",
802 description: d::OBSERVE,
803 input_schema: schema(
804 &[("path", s()), ("content", s())],
805 &["path", "content"],
806 true,
807 ),
808 },
809 ToolSpec {
810 name: "observe_many",
811 description: d::OBSERVE_MANY,
812 input_schema: schema(
813 &[(
814 "files",
815 json!({ "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "content": { "type": "string" } }, "required": ["path", "content"] } }),
816 )],
817 &["files"],
818 true,
819 ),
820 },
821 ToolSpec {
822 name: "observe_delete",
823 description: d::OBSERVE_DELETE,
824 input_schema: schema(&[("path", s())], &["path"], true),
825 },
826 ToolSpec {
827 name: "blocks_history",
828 description: d::BLOCKS_HISTORY,
829 input_schema: schema(&[("_id", s()), ("limit", i())], &["_id"], false),
830 },
831 ToolSpec {
832 name: "docs_diff",
833 description: d::DOCS_DIFF,
834 input_schema: doc(
835 &[("from_rev", s()), ("to_rev", s())],
836 &["from_rev", "to_rev"],
837 ),
838 },
839 ToolSpec {
840 name: "docs_diff_unified",
841 description: d::DOCS_DIFF_UNIFIED,
842 input_schema: doc(&[("from_rev", s()), ("to_rev", s())], &[]),
843 },
844 ToolSpec {
845 name: "docs_read_at",
846 description: d::DOCS_READ_AT,
847 input_schema: doc(&[("rev", s())], &["rev"]),
848 },
849 ToolSpec {
850 name: "docs_history",
851 description: d::DOCS_HISTORY,
852 input_schema: schema(
853 &[
854 ("path_glob", s()),
855 ("_id", s()),
856 ("_path", s()),
857 ("include_deleted", b()),
858 ("limit", i()),
859 ],
860 &[],
861 true,
862 ),
863 },
864 ToolSpec {
865 name: "changes_since",
866 description: d::CHANGES_SINCE,
867 input_schema: schema(
868 &[
869 ("cursor", i()),
870 (
871 "origin",
872 json!({ "type": "string", "enum": ["api", "observed", "import"] }),
873 ),
874 ("limit", i()),
875 ],
876 &[],
877 true,
878 ),
879 },
880 ToolSpec {
881 name: "repos_status",
882 description: d::REPOS_STATUS,
883 input_schema: schema(&[], &[], true),
884 },
885 ToolSpec {
886 name: "sync_status",
887 description: d::SYNC_STATUS,
888 input_schema: schema(&[], &[], true),
889 },
890 ToolSpec {
891 name: "repos",
892 description: d::REPOS,
893 input_schema: schema(&[], &[], false),
894 },
895 ToolSpec {
896 name: "version",
897 description: d::VERSION,
898 input_schema: schema(&[], &[], false),
899 },
900 ]
901}
902
903impl Surface {
904 #[must_use]
907 pub fn new(
908 store: Store,
909 default_repo: &str,
910 provider: Option<Box<dyn EmbeddingProvider>>,
911 ) -> Self {
912 let _ = store
923 .conn()
924 .execute_batch("PRAGMA cache_size = -16000; PRAGMA temp_store = MEMORY;");
925 Self {
926 store,
927 default_repo: default_repo.to_owned(),
928 provider,
929 provider_name: None,
930 on_mutation: None,
931 clock: Box::new(omgbase_sync::now_ts),
932 writes: WriteTarget::Derived,
933 config: Config::default(),
934 actor: ACTOR.to_owned(),
935 build_info: BuildInfo::default(),
936 preview: false,
937 last_plan: None,
938 profile: Profile::Full,
939 vocabulary: VocabularyCache::default(),
940 }
941 }
942
943 #[must_use]
948 pub fn with_profile(mut self, profile: Profile) -> Self {
949 self.profile = profile;
950 self
951 }
952
953 #[must_use]
955 pub fn profile(&self) -> Profile {
956 self.profile
957 }
958
959 #[must_use]
962 pub fn with_provider_name(mut self, name: &str) -> Self {
963 self.provider_name = Some(name.to_owned());
964 self
965 }
966
967 #[must_use]
970 pub fn with_actor(mut self, actor: &str) -> Self {
971 self.set_actor(actor);
972 self
973 }
974
975 pub fn set_actor(&mut self, actor: &str) {
977 self.actor = actor.to_owned();
978 }
979
980 #[must_use]
983 pub fn with_doc_store(mut self, doc_store: Box<dyn DocStore>) -> Self {
984 self.writes = WriteTarget::Fixed(doc_store);
985 self
986 }
987
988 #[must_use]
990 pub fn with_clock(mut self, clock: impl FnMut() -> String + 'static) -> Self {
991 self.clock = Box::new(clock);
992 self
993 }
994
995 #[must_use]
997 pub fn with_mutation_hook(mut self, hook: impl FnMut() + 'static) -> Self {
998 self.on_mutation = Some(Box::new(hook));
999 self
1000 }
1001
1002 #[must_use]
1004 pub fn with_config(mut self, config: Config) -> Self {
1005 self.config = config;
1006 self
1007 }
1008
1009 #[must_use]
1013 pub fn with_build_info(mut self, build_info: BuildInfo) -> Self {
1014 self.build_info = build_info;
1015 self
1016 }
1017
1018 #[must_use]
1019 pub fn store(&self) -> &Store {
1020 &self.store
1021 }
1022
1023 pub fn store_mut(&mut self) -> &mut Store {
1024 &mut self.store
1025 }
1026
1027 #[must_use]
1028 pub fn default_repo(&self) -> &str {
1029 &self.default_repo
1030 }
1031
1032 #[must_use]
1035 pub fn tools(&self) -> Vec<ToolSpec> {
1036 self.profile.tools()
1037 }
1038
1039 #[must_use]
1043 pub fn is_write_tool(name: &str) -> bool {
1044 matches!(
1045 name,
1046 "apply"
1047 | "plan"
1048 | "execute"
1049 | "blocks_insert"
1050 | "blocks_update"
1051 | "blocks_move"
1052 | "blocks_remove"
1053 | "blocks_split"
1054 | "blocks_merge"
1055 | "blocks_check"
1056 | "nodes_set"
1057 | "blocks_append"
1058 | "docs_append"
1059 | "links_retarget"
1060 | "links_repair"
1061 | "docs_create"
1062 | "docs_move"
1063 | "docs_delete"
1064 | "docs_set_meta"
1065 | "docs_update"
1066 | "observe"
1067 | "observe_many"
1068 | "observe_delete"
1069 )
1070 }
1071
1072 pub fn call(&mut self, name: &str, args: Json) -> ToolOutcome {
1074 match self.call_result(name, &args) {
1075 Ok(body) => ToolOutcome {
1076 body,
1077 is_error: false,
1078 },
1079 Err(e) => ToolOutcome {
1080 body: e.to_json(),
1081 is_error: true,
1082 },
1083 }
1084 }
1085
1086 fn now(&mut self) -> String {
1087 (self.clock)()
1088 }
1089
1090 fn notify(&mut self) {
1091 if let Some(hook) = &mut self.on_mutation {
1092 hook();
1093 }
1094 }
1095
1096 fn repo_rows(&self) -> Result<Vec<RepoRow>> {
1099 Ok(omgbase_sync::workspace::list_repos(&self.store)?)
1100 }
1101
1102 fn scope(&self, args: &Json) -> Result<(String, Option<String>)> {
1104 let rows = self.repo_rows()?;
1105 match arg_str(args, "repo") {
1106 None => {
1107 let root = rows
1108 .iter()
1109 .find(|r| r.repo_id == self.default_repo)
1110 .and_then(|r| r.root_path.clone());
1111 Ok((self.default_repo.clone(), root))
1112 }
1113 Some(slug) => rows
1114 .iter()
1115 .find(|r| r.slug == slug)
1116 .map(|r| (r.repo_id.clone(), r.root_path.clone()))
1117 .ok_or_else(|| {
1118 SurfaceError::with_data(
1119 "repo_not_found",
1120 format!("no repo '{slug}' in this workspace"),
1121 json!({ "repo": slug }),
1122 )
1123 }),
1124 }
1125 }
1126
1127 fn require_root(&self, root: Option<&str>) -> Result<()> {
1128 if matches!(self.writes, WriteTarget::Fixed(_)) || root.is_some() {
1129 Ok(())
1130 } else {
1131 Err(SurfaceError::new(
1132 "repo_not_found",
1133 "repo has no filesystem source; mutation disabled",
1134 ))
1135 }
1136 }
1137
1138 fn with_writes<T>(
1140 &mut self,
1141 root: Option<&str>,
1142 f: impl FnOnce(&mut Store, &mut dyn DocStore) -> Result<T>,
1143 ) -> Result<T> {
1144 self.require_root(root)?;
1145 match &mut self.writes {
1146 WriteTarget::Fixed(ds) => f(&mut self.store, ds.as_mut()),
1147 WriteTarget::Derived => {
1148 let mut fs = FsDocStore::new(root.expect("checked by require_root"));
1149 f(&mut self.store, &mut fs)
1150 }
1151 }
1152 }
1153
1154 pub fn locate_document(&self, repo_id: &str, args: &Json) -> Result<String> {
1172 self.locate_doc(repo_id, "docs_update", args)
1173 }
1174
1175 fn docs_read_at(&self, doc_id: &str, rev: &str) -> Result<Json> {
1180 read::docs_read_at(&self.store, doc_id, rev)?.ok_or_else(|| {
1181 SurfaceError::with_data(
1182 "target_missing",
1183 format!(
1184 "no revision {} for document {doc_id}",
1185 Json::String(rev.to_owned())
1186 ),
1187 json!({ "doc": doc_id, "rev": rev }),
1188 )
1189 })
1190 }
1191
1192 fn locate_doc(&self, repo_id: &str, tool: &str, args: &Json) -> Result<String> {
1193 let (id_key, path_key) = ("_id", "_path");
1194 let schema = &tool_spec(tool).expect("a catalog tool").input_schema;
1195 let id = arg_str(args, id_key);
1196 let path = arg_str(args, path_key);
1197 if id.is_none() && path.is_none() {
1198 return Err(argument_error(
1199 tool,
1200 &format!("pass {id_key} or {path_key}"),
1201 schema,
1202 ));
1203 }
1204 let mut given = Map::new();
1205 if let Some(v) = id {
1206 given.insert(id_key.to_owned(), json!(v));
1207 }
1208 if let Some(v) = path {
1209 given.insert(path_key.to_owned(), json!(v));
1210 }
1211 let missing = |value: &str| {
1212 SurfaceError::with_data(
1213 "doc_missing",
1214 format!(
1215 "no document with {id_key} or {path_key} {}",
1216 Json::String(value.to_owned())
1217 ),
1218 Json::Object(given.clone()),
1219 )
1220 };
1221 let conn = self.store.conn();
1222 let by_id = match id {
1223 Some(v) => {
1224 let info = read::find_doc_by_id(conn, v)?.filter(|i| i.repo_id == repo_id);
1225 Some(info.ok_or_else(|| missing(v))?.doc_id)
1226 }
1227 None => None,
1228 };
1229 let by_path = match path {
1230 Some(v) => Some(
1231 read::find_doc_by_path(conn, repo_id, v)?
1232 .ok_or_else(|| missing(v))?
1233 .doc_id,
1234 ),
1235 None => None,
1236 };
1237 if let (Some(a), Some(b)) = (&by_id, &by_path)
1238 && a != b
1239 {
1240 return Err(argument_error(
1241 tool,
1242 &format!(
1243 "{id_key} {} and {path_key} {} name different documents",
1244 Json::String(id.unwrap_or_default().to_owned()),
1245 Json::String(path.unwrap_or_default().to_owned())
1246 ),
1247 schema,
1248 ));
1249 }
1250 Ok(by_id.or(by_path).expect("one locator given"))
1251 }
1252
1253 fn require_rankable(&self, repo_id: &str, semantic: bool) -> Result<()> {
1260 let Some(provider) = self.provider.as_deref().filter(|_| semantic) else {
1261 return Ok(());
1262 };
1263 let model = provider.model();
1264 let any: Option<i64> = self
1265 .store
1266 .conn()
1267 .query_row(
1268 "SELECT 1 WHERE EXISTS (SELECT 1 FROM doc_embeddings de JOIN docs d ON d.doc_id = de.doc_id WHERE d.repo_id = ?1 AND de.model = ?2) OR EXISTS (SELECT 1 FROM embeddings WHERE model = ?2)",
1269 params![repo_id, model],
1270 |r| r.get(0),
1271 )
1272 .optional()?;
1273 if any.is_some() {
1274 return Ok(());
1275 }
1276 let who = match &self.provider_name {
1277 Some(name) => format!("provider {} (model {model})", Json::String(name.clone())),
1278 None => format!("model {model}"),
1279 };
1280 Err(SurfaceError::with_data(
1281 "provider_unavailable",
1282 format!(
1283 "semantic(...) cannot rank: {who} is configured but nothing in this repository is embedded under it — run omg embed drain"
1284 ),
1285 json!({ "provider": self.provider_name, "model": model }),
1286 ))
1287 }
1288
1289 fn doc_of_block(&self, block_id: &str) -> Result<String> {
1291 self.doc_id_of_block(block_id)?
1292 .ok_or_else(|| block_missing(block_id))
1293 }
1294
1295 fn resolve_section_heading(
1309 &self,
1310 doc_id: &str,
1311 doc_path: &str,
1312 section: &str,
1313 call: (Option<&str>, Option<&str>, &str),
1314 ) -> Result<String> {
1315 let conn = self.store.conn();
1316 let needle = if section.trim_start_matches([' ', '\t']).starts_with('#') {
1317 normalize_visible_text(section, BlockKind::Heading, 0)
1318 } else {
1319 normalize_text(section)
1320 };
1321 let mut stmt = conn.prepare(
1322 "SELECT block_id, doc_id, text FROM blocks WHERE doc_id = ?1 AND type = 'heading' AND deleted_commit IS NULL",
1323 )?;
1324 let rows: Vec<(String, String, String)> = stmt
1325 .query_map(params![doc_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
1326 .collect::<std::result::Result<_, _>>()?;
1327 let matches: Vec<&(String, String, String)> = rows
1328 .iter()
1329 .filter(|(_, _, text)| normalize_text(text) == needle)
1330 .collect();
1331 let shown = reference_path(doc_path);
1332 let (id, path, text) = call;
1333 match matches.len() {
1334 0 => {
1335 let near: Vec<&(String, String, String)> = rows
1336 .iter()
1337 .filter(|(_, _, t)| normalize_text(t).eq_ignore_ascii_case(&needle))
1338 .collect();
1339 let fix = match near.as_slice() {
1340 [one] => fixes::append_into_heading(&one.0, text),
1341 _ => fixes::append_without_section(id, path, text),
1342 };
1343 Err(SurfaceError::with_data(
1344 "parent_missing",
1345 format!("no heading {} in {shown}", Json::String(section.to_owned())),
1346 json!({ "section": section, "_path": shown, "fix": fix }),
1347 ))
1348 }
1349 1 => Ok(matches[0].0.clone()),
1350 n => Err(SurfaceError::with_data(
1351 "ambiguous_heading",
1352 format!(
1353 "heading {} matches {n} headings in {shown}; pass the heading's id to blocks_append",
1354 Json::String(section.to_owned())
1355 ),
1356 json!({
1357 "section": section,
1358 "_path": shown,
1359 "candidates": matches.iter().map(|(b, d, _)| json!({ "_id": b, "_doc_id": d })).collect::<Vec<_>>(),
1360 "fix": fixes::append_into_heading(&matches[0].0, text),
1361 }),
1362 )),
1363 }
1364 }
1365
1366 fn block_of_node(&self, node_id: &str) -> Result<(String, String)> {
1370 let node: Option<(String, Option<String>)> = self
1371 .store
1372 .conn()
1373 .query_row(
1374 "SELECT doc_id, block_id FROM nodes WHERE node_id = ?1",
1375 params![node_id],
1376 |r| Ok((r.get(0)?, r.get(1)?)),
1377 )
1378 .optional()?;
1379 let Some((doc_id, block_id)) = node else {
1380 return Err(SurfaceError::with_data(
1381 "block_missing",
1382 format!("no node with id {}", Json::String(node_id.to_owned())),
1383 json!({ "_id": node_id }),
1384 ));
1385 };
1386 let live = match &block_id {
1387 Some(b) => self.doc_id_of_block(b)?.is_some(),
1388 None => false,
1389 };
1390 match block_id {
1391 Some(b) if live => Ok((doc_id, b)),
1392 _ => Err(node_has_no_block(node_id, &doc_id)),
1393 }
1394 }
1395
1396 fn resolve_block_ref(&self, repo_id: &str, r: &str) -> Result<String> {
1398 match read::resolve_ref(self.store.conn(), repo_id, r)? {
1399 Some(ResolvedRef::Block { block_id, .. }) => Ok(block_id),
1400 _ => Err(block_missing(r)),
1401 }
1402 }
1403
1404 fn resolve_parent_ref(&self, repo_id: &str, r: &str) -> Result<(Parent, String)> {
1406 match read::resolve_ref(self.store.conn(), repo_id, r)? {
1407 Some(ResolvedRef::Block { doc_id, block_id }) => Ok((Parent::Block(block_id), doc_id)),
1408 Some(ResolvedRef::Document { doc_id }) => Ok((Parent::Doc, doc_id)),
1409 None => Err(SurfaceError::with_data(
1410 "block_missing",
1411 format!(
1412 "no block or document with id or path {}",
1413 Json::String(r.to_owned())
1414 ),
1415 json!({ "ref": r }),
1416 )),
1417 }
1418 }
1419
1420 fn doc_id_of_block(&self, block_id: &str) -> Result<Option<String>> {
1421 Ok(self
1422 .store
1423 .conn()
1424 .query_row(
1425 "SELECT doc_id FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
1426 params![block_id],
1427 |r| r.get(0),
1428 )
1429 .optional()?)
1430 }
1431
1432 fn resolve_at(&self, repo_id: &str, at: Option<&Json>) -> Result<At> {
1434 match at {
1435 None | Some(Json::Null) => Ok(At::End),
1436 Some(Json::String(s)) if s == "start" => Ok(At::Start),
1437 Some(Json::String(s)) if s == "end" => Ok(At::End),
1438 Some(Json::Object(o)) => {
1439 if let Some(b) = o.get("before").and_then(Json::as_str) {
1440 return Ok(At::Before(self.resolve_block_ref(repo_id, b)?));
1441 }
1442 if let Some(a) = o.get("after").and_then(Json::as_str) {
1443 return Ok(At::After(self.resolve_block_ref(repo_id, a)?));
1444 }
1445 Err(bad_args(
1446 "`at` must be \"start\", \"end\", {before} or {after}",
1447 ))
1448 }
1449 Some(_) => Err(bad_args(
1450 "`at` must be \"start\", \"end\", {before} or {after}",
1451 )),
1452 }
1453 }
1454
1455 fn apply_sugar(
1463 &mut self,
1464 repo_id: &str,
1465 root: Option<&str>,
1466 effects: Vec<PublicEffect>,
1467 reason: &str,
1468 ) -> Result<Json> {
1469 let out = self.apply_sugar_with(repo_id, root, reason, |_, _| Ok(effects))?;
1470 Ok(sugar_json(&out))
1471 }
1472
1473 fn apply_sugar_with(
1480 &mut self,
1481 repo_id: &str,
1482 root: Option<&str>,
1483 reason: &str,
1484 build: impl FnOnce(&mut Store, &mut dyn DocStore) -> Result<Vec<PublicEffect>>,
1485 ) -> Result<SugarOutcome> {
1486 let origin = ApplyOrigin::new(&self.actor, Some(reason));
1487 let ctx = self.plan_ctx(repo_id);
1488 let preview = self.preview;
1489 let out = self.with_writes(root, |store, ds| {
1490 let effects = build(store, ds)?;
1491 if preview {
1492 let plan = store
1493 .plan_effects(&ctx, &effects, ds)
1494 .map_err(strip_effect_index)?;
1495 return Ok((plan, None));
1496 }
1497 let (plan, receipt) = store
1498 .apply_effects_detailed(&ctx, &effects, &origin, ds)
1499 .map_err(strip_effect_index)?;
1500 Ok((plan, Some(receipt)))
1501 })?;
1502 self.last_plan = Some(out.0.clone());
1503 if out.1.is_some() {
1504 self.notify();
1505 }
1506 Ok(out)
1507 }
1508
1509 fn doc_info(&self, repo_id: &str, doc_ref: &str) -> Result<omgbase_store::DocInfo> {
1512 self.store
1513 .find_doc_by_ref(repo_id, doc_ref)?
1514 .ok_or_else(|| {
1515 SurfaceError::from(MutationError::new(
1516 ErrorCode::DocMissing,
1517 format!("no document {doc_ref}"),
1518 ))
1519 })
1520 }
1521
1522 pub fn call_result_with_plan(
1530 &mut self,
1531 name: &str,
1532 args: &Json,
1533 ) -> Result<(Json, Option<Json>)> {
1534 self.last_plan = None;
1535 let result = self.call_result(name, args);
1536 let plan = self.last_plan.take().map(|p| plan_json(&p));
1537 Ok((result?, plan))
1538 }
1539
1540 pub fn preview_call(&mut self, name: &str, args: &Json) -> Result<Json> {
1547 self.preview = true;
1548 let out = self.call_result(name, args);
1549 self.preview = false;
1550 out
1551 }
1552
1553 fn apply_effects_detailed(
1560 &mut self,
1561 repo: &str,
1562 root: Option<&str>,
1563 args: &Json,
1564 ) -> Result<(Json, Option<Json>)> {
1565 let reason = arg_str(args, "reason").map(str::to_owned);
1566 let origin = ApplyOrigin::new(&self.actor, reason.as_deref());
1567 let ctx = self.plan_ctx(repo);
1568 let preview = self.preview;
1569 let (plan, receipt) = match SelectRequest::from_args(args)? {
1570 Some(req) => {
1571 let semantic = self.selector_semantic(repo, req.select_str())?;
1572 self.with_writes(root, |store, ds| {
1573 if preview {
1574 return Ok((
1575 select::plan_selected(store, ds, &ctx, &req, semantic)?,
1576 None,
1577 ));
1578 }
1579 if req.has_selector() && req.expect.is_none() {
1585 let x = select::expand(store, &ctx, ds, &req, semantic)?;
1586 let cardinality = x.selection.map_or(0, |s| s.cardinality);
1587 return Err(select::unbounded_apply(&req, cardinality));
1588 }
1589 let (plan, receipt) = store.apply_planned(&ctx, &origin, ds, |store, ds| {
1590 select::plan_selected(store, ds, &ctx, &req, semantic)
1591 })?;
1592 Ok((plan, Some(receipt)))
1593 })?
1594 }
1595 None => {
1596 self.with_writes(root, |store, ds| {
1597 let effects = select::validate_plain(store, &args["effects"])?;
1600 if preview {
1601 return Ok((store.plan_effects(&ctx, &effects, ds)?, None));
1602 }
1603 let (plan, receipt) =
1604 store.apply_effects_detailed(&ctx, &effects, &origin, ds)?;
1605 Ok((plan, Some(receipt)))
1606 })?
1607 }
1608 };
1609 self.last_plan = Some(plan.clone());
1610 if receipt.is_some() {
1611 self.notify();
1612 }
1613 Ok((plan_json(&plan), receipt.as_ref().map(receipt_json)))
1614 }
1615
1616 fn selector_semantic(
1621 &self,
1622 repo_id: &str,
1623 select: &str,
1624 ) -> Result<HashMap<String, crate::context::SemanticVec>> {
1625 let phrases = crate::query::collect_semantic_phrases(select);
1626 if self.provider.is_none() && !phrases.is_empty() {
1627 return Err(provider_unavailable());
1628 }
1629 self.require_rankable(repo_id, !phrases.is_empty())?;
1630 embed_phrases(self.provider.as_deref(), phrases)
1631 }
1632
1633 fn plan_ctx(&mut self, repo_id: &str) -> PlanContext {
1634 PlanContext {
1635 repo_id: repo_id.to_owned(),
1636 ts: self.now(),
1637 config: self.config.clone(),
1638 }
1639 }
1640
1641 pub fn call_result(&mut self, name: &str, args: &Json) -> Result<Json> {
1647 self.call_result_inner(name, args)
1648 .map_err(|e| fixes::attach(name, args, e, |i| self.allocated_before(args, i)))
1649 }
1650
1651 fn allocated_before(&mut self, args: &Json, i: usize) -> Option<usize> {
1655 if i == 0 {
1656 return Some(0);
1657 }
1658 let prefix = args.get("effects")?.as_array()?.get(..i)?.to_vec();
1659 let effects = parse_effects(&Json::Array(prefix)).ok()?;
1660 let (repo, root) = self.scope(args).ok()?;
1661 let ctx = self.plan_ctx(&repo);
1662 self.with_writes(root.as_deref(), |store, ds| {
1663 Ok(store.plan_effects(&ctx, &effects, ds)?)
1664 })
1665 .ok()
1666 .map(|p| p.new_ids.len())
1667 }
1668
1669 #[allow(clippy::too_many_lines)]
1670 fn call_result_inner(&mut self, name: &str, args: &Json) -> Result<Json> {
1671 let spec =
1674 tool_spec(name).ok_or_else(|| SurfaceError::other(format!("unknown tool {name}")))?;
1675 let normalized = validate_arguments(name, &spec.input_schema, args)?;
1677 let args = normalized.as_ref().unwrap_or(args);
1678 match name {
1679 "docs_outline" => {
1680 let (repo, _) = self.scope(args)?;
1681 let doc_id = self.locate_doc(&repo, name, args)?;
1682 let skeleton = match arg_str(args, "resolution") {
1683 None | Some("outline") => false,
1684 Some("skeleton") => true,
1685 Some(_) => return Err(bad_args("`resolution` must be skeleton or outline")),
1686 };
1687 read::docs_outline(
1688 &self.store,
1689 &doc_id,
1690 skeleton,
1691 arg_i64(args, "depth")?,
1692 arg_usize(args, "budget_tokens")?,
1693 )
1694 }
1695 "docs_read" => {
1696 let (repo, _) = self.scope(args)?;
1697 let doc_id = self.locate_doc(&repo, name, args)?;
1698 if let Some(rev) = arg_str(args, "at") {
1699 if arg_bool(args, "include_ids")?.unwrap_or(false) {
1705 return Err(argument_error(
1706 name,
1707 "`include_ids` is not available with `at`",
1708 &tool_spec(name).expect("a catalog tool").input_schema,
1709 ));
1710 }
1711 return self.docs_read_at(&doc_id, rev);
1712 }
1713 read::docs_read(
1714 &self.store,
1715 &doc_id,
1716 arg_bool(args, "include_ids")?.unwrap_or(false),
1717 )?
1718 .ok_or_else(|| {
1719 SurfaceError::with_data(
1720 "doc_missing",
1721 format!(
1722 "no document with id or path {}",
1723 Json::String(
1724 arg_str(args, "_id")
1725 .or(arg_str(args, "_path"))
1726 .unwrap_or_default()
1727 .to_owned()
1728 )
1729 ),
1730 json!({ "_id": doc_id }),
1731 )
1732 })
1733 }
1734 "docs_read_many" => {
1735 let (repo, _) = self.scope(args)?;
1736 let refs = arg_strings(args, "docs")?;
1737 read::docs_read_many(
1738 &self.store,
1739 &repo,
1740 &refs,
1741 arg_bool(args, "include_ids")?.unwrap_or(false),
1742 arg_usize(args, "budget_tokens")?,
1743 )
1744 }
1745 "blocks_read" => {
1746 self.scope(args)?;
1747 let id = arg_string(args, "_id")?;
1748 if id.starts_with("n_") {
1753 return Err(SurfaceError::with_data(
1754 "block_missing",
1755 format!(
1756 "no block with id {} (an n_ id names a node — nodes_block_read reads its block)",
1757 Json::String(id.clone())
1758 ),
1759 json!({ "_id": id }),
1760 ));
1761 }
1762 let doc_id = self.doc_of_block(&id)?;
1763 read::blocks_read(
1764 &self.store,
1765 &doc_id,
1766 &id,
1767 resolution_arg(args, Resolution::Full)?,
1768 )?
1769 .ok_or_else(|| block_missing(&id))
1770 }
1771 "nodes_block_read" => {
1772 self.scope(args)?;
1773 let id = arg_string(args, "_id")?;
1774 let (doc_id, block_id) = self.block_of_node(&id)?;
1779 read::blocks_read(
1780 &self.store,
1781 &doc_id,
1782 &block_id,
1783 resolution_arg(args, Resolution::Full)?,
1784 )?
1785 .ok_or_else(|| node_has_no_block(&id, &doc_id))
1786 }
1787 "blocks_read_many" => {
1788 self.scope(args)?;
1789 let ids = arg_strings(args, "_ids")?;
1790 read::blocks_read_many(
1791 &self.store,
1792 None,
1793 &ids,
1794 resolution_arg(args, Resolution::Text)?,
1795 arg_usize(args, "budget_tokens")?,
1796 )
1797 }
1798 "docs_tree" => {
1799 let (repo, _) = self.scope(args)?;
1800 read::docs_tree(
1801 &self.store,
1802 &repo,
1803 arg_str(args, "path"),
1804 arg_i64(args, "depth")?,
1805 arg_i64(args, "limit")?,
1806 arg_str(args, "cursor"),
1807 arg_usize(args, "budget_tokens")?,
1808 )
1809 }
1810 "docs_list" => {
1811 let (repo, _) = self.scope(args)?;
1812 read::docs_list(
1813 &self.store,
1814 &repo,
1815 arg_str(args, "path_glob"),
1816 arg_i64(args, "limit")?,
1817 arg_str(args, "cursor"),
1818 arg_usize(args, "budget_tokens")?,
1819 )
1820 }
1821 "query_syntax" => Ok(json!({ "syntax": QUERY_SYNTAX })),
1822 "query" => {
1823 let (repo, _) = self.scope(args)?;
1824 let source = arg_string(args, "query")?;
1825 if self.provider.is_none()
1828 && !crate::query::collect_semantic_phrases(&source).is_empty()
1829 {
1830 return Err(provider_unavailable());
1831 }
1832 let now_ms = dates::stamp_ms(&self.now());
1836 let opts = QueryOptions {
1837 limit: arg_usize(args, "limit")?,
1838 cursor: arg_str(args, "cursor"),
1839 provider: self.provider.as_deref(),
1840 in_memory: false,
1841 bare: arg_bool(args, "bare")?.unwrap_or(false),
1842 unknown_names: None,
1843 vocabulary: Some(&self.vocabulary),
1844 now_ms: Some(now_ms),
1845 request: Some(args),
1847 };
1848 self.require_rankable(
1849 &repo,
1850 !crate::query::collect_semantic_phrases(&source).is_empty(),
1851 )?;
1852 Ok(query(&self.store, &repo, &source, opts)?.to_json())
1853 }
1854 "graph" => {
1855 let (repo, _) = self.scope(args)?;
1856 let g = GraphArgs {
1857 roots: arg_strings(args, "roots")?,
1858 degrees: arg_i64(args, "degrees")?,
1859 direction: arg_str(args, "direction").map(str::to_owned),
1860 predicate: arg_str(args, "predicate").map(str::to_owned),
1861 select: if args.get("select").is_some() {
1862 arg_strings(args, "select")?
1863 } else {
1864 Vec::new()
1865 },
1866 max_documents: arg_i64(args, "max_documents")?,
1867 };
1868 if self.provider.is_none()
1869 && g.select.iter().any(|s| {
1870 !crate::query::collect_semantic_phrases(&format!("from docs select x: {s}"))
1871 .is_empty()
1872 })
1873 {
1874 return Err(provider_unavailable());
1875 }
1876 self.require_rankable(
1877 &repo,
1878 g.select.iter().any(|s| {
1879 !crate::query::collect_semantic_phrases(&format!("from docs select x: {s}"))
1880 .is_empty()
1881 }),
1882 )?;
1883 let now_ms = dates::stamp_ms(&self.now());
1884 graph_neighborhood(
1885 &self.store,
1886 &repo,
1887 &g,
1888 self.provider.as_deref(),
1889 Some(now_ms),
1890 )
1891 }
1892 "blocks_search" => {
1893 let (repo, _) = self.scope(args)?;
1894 let q = arg_string(args, "q")?;
1895 let res =
1896 self.store
1897 .text_search(&repo, &q, arg_usize(args, "limit")?.unwrap_or(50))?;
1898 Ok(json!({
1899 "hits": res.hits.iter().map(|h| json!({
1900 "_id": h.block_id, "_doc_id": h.doc_id, "_path": reference_path(&h.path), "_type": h.block_type, "_text": h.text, "score": h.score,
1901 })).collect::<Vec<_>>(),
1902 "truncated": res.truncated,
1903 }))
1904 }
1905 "blocks_resolve" => {
1906 let (repo, _) = self.scope(args)?;
1907 let q = arg_string(args, "query")?;
1908 let vector = match &self.provider {
1909 Some(p) => Some(QueryVector {
1910 model: p.model().to_owned(),
1911 vec: p
1912 .embed_query(&q)
1913 .map_err(|e| SurfaceError::new(e.code(), e.to_string()))?,
1914 }),
1915 None => None,
1916 };
1917 let hits = self
1918 .store
1919 .resolve(&repo, &q, vector, arg_usize(args, "limit")?)?;
1920 let locator = |l: &str| match l.find('#') {
1923 Some(i) => format!("{}{}", reference_path(&l[..i]), &l[i..]),
1924 None => l.to_owned(),
1925 };
1926 Ok(Json::Array(hits.iter().map(|h| json!({
1927 "_id": h.id, "locator": locator(&h.locator), "preview": h.preview, "evidence": evidence_json(&h.evidence),
1928 })).collect()))
1929 }
1930 "apply" => {
1931 let (repo, root) = self.scope(args)?;
1932 self.require_root(root.as_deref())?;
1933 let (plan, receipt) = self.apply_effects_detailed(&repo, root.as_deref(), args)?;
1936 Ok(receipt.unwrap_or(plan))
1937 }
1938 "plan" => {
1939 let (repo, root) = self.scope(args)?;
1940 self.require_root(root.as_deref())?;
1941 let ctx = self.plan_ctx(&repo);
1942 let plan = match SelectRequest::from_args(args)? {
1945 Some(req) => {
1946 let semantic = self.selector_semantic(&repo, req.select_str())?;
1947 self.with_writes(root.as_deref(), |store, ds| {
1948 select::plan_selected(store, ds, &ctx, &req, semantic)
1949 })?
1950 }
1951 None => self.with_writes(root.as_deref(), |store, ds| {
1952 let effects = select::validate_plain(store, &args["effects"])?;
1953 Ok(store.plan_effects(&ctx, &effects, ds)?)
1954 })?,
1955 };
1956 let mut plan = plan_json(&plan);
1957 if args.get("diffs").and_then(Json::as_bool) != Some(true)
1962 && let Some(preview) = plan.get_mut("preview").and_then(Json::as_object_mut)
1963 {
1964 preview.remove("diffs");
1965 }
1966 Ok(plan)
1967 }
1968 "execute" => {
1969 let (repo, root) = self.scope(args)?;
1970 self.require_root(root.as_deref())?;
1971 let reason = arg_str(args, "reason").map(str::to_owned);
1972 let origin = ApplyOrigin::new(&self.actor, reason.as_deref());
1973 let ctx = self.plan_ctx(&repo);
1974 let plan_form = args.get("plan").is_some();
1978 let digest_form = args.get("plan_id").is_some()
1979 || args.get("effects").is_some()
1980 || args.get("select").is_some()
1981 || args.get("expect").is_some();
1982 if plan_form == digest_form {
1983 return Err(argument_error(
1984 name,
1985 "pass exactly one of `plan` or `plan_id` + `effects`",
1986 &spec.input_schema,
1987 ));
1988 }
1989 let receipt = if plan_form {
1990 let plan = match Plan::admit(&args["plan"]) {
1991 Ok(plan) => plan,
1992 Err(Admission::Refused(m)) => return Err(SurfaceError::from(m)),
1993 Err(Admission::Invalid(m)) => {
1994 return Err(SurfaceError::filter_invalid(m, "arguments"));
1995 }
1996 };
1997 self.with_writes(root.as_deref(), |store, ds| {
1998 Ok(store.execute_plan(&ctx, &plan, &origin, ds)?)
1999 })?
2000 } else {
2001 let (Some(plan_id), Some(effects)) =
2002 (arg_str(args, "plan_id"), args.get("effects"))
2003 else {
2004 return Err(argument_error(
2005 name,
2006 "the digest form needs both `plan_id` and `effects`",
2007 &spec.input_schema,
2008 ));
2009 };
2010 let plan_id = plan_id.to_owned();
2011 match SelectRequest::from_args(args)? {
2012 Some(req) => {
2013 let semantic = self.selector_semantic(&repo, req.select_str())?;
2014 self.with_writes(root.as_deref(), |store, ds| {
2015 store.execute_replanned(&ctx, &plan_id, &origin, ds, |store, ds| {
2016 select::plan_selected(store, ds, &ctx, &req, semantic)
2017 })
2018 })?
2019 }
2020 None => self.with_writes(root.as_deref(), |store, ds| {
2021 let effects = select::validate_plain(store, effects)?;
2022 Ok(store.execute_by_digest(&ctx, &plan_id, &effects, &origin, ds)?)
2023 })?,
2024 }
2025 };
2026 self.notify();
2027 Ok(receipt_json(&receipt))
2028 }
2029 "blocks_insert" => {
2031 let (repo, root) = self.scope(args)?;
2032 self.require_root(root.as_deref())?;
2033 let (parent, doc_id) = self.resolve_parent_ref(&repo, &arg_string(args, "to")?)?;
2034 let at = self.resolve_at(&repo, args.get("at"))?;
2035 let effects = sugar::insert(
2036 placement_target(parent, doc_id),
2037 at,
2038 &arg_string(args, "markdown")?,
2039 effect_expect(arg_parent_expect(args)?),
2040 );
2041 self.apply_sugar(&repo, root.as_deref(), effects, "blocks_insert")
2042 }
2043 "blocks_update" => {
2044 let (repo, root) = self.scope(args)?;
2045 self.require_root(root.as_deref())?;
2046 let block = self.resolve_block_ref(&repo, &arg_string(args, "_id")?)?;
2047 let markdown = arg_str(args, "markdown");
2048 let checked = arg_bool(args, "checked")?;
2049 let effects = sugar::update_block(
2054 &block,
2055 markdown,
2056 checked,
2057 effect_expect(arg_expect(args)?),
2058 );
2059 if effects.is_empty() {
2060 return Err(argument_error(
2061 name,
2062 "pass `markdown` and/or `checked`",
2063 &spec.input_schema,
2064 ));
2065 }
2066 self.apply_sugar(&repo, root.as_deref(), effects, "blocks_update")
2067 }
2068 "blocks_move" => {
2069 let (repo, root) = self.scope(args)?;
2070 self.require_root(root.as_deref())?;
2071 let blocks: Vec<String> =
2072 require_some(name, "blocks", arg_strings(args, "blocks")?, spec)?
2073 .iter()
2074 .map(|b| self.resolve_block_ref(&repo, b))
2075 .collect::<Result<_>>()?;
2076 let to_ref = arg_string(args, "to")?;
2077 let (parent, doc_id) = self.resolve_parent_ref(&repo, &to_ref)?;
2078 if parent == Parent::Doc {
2079 if let Some(first) = blocks.first() {
2080 if self.doc_id_of_block(first)? != Some(doc_id.clone()) {
2081 return Err(SurfaceError::with_data(
2082 "target_missing",
2083 format!(
2084 "blocks_move cannot target another document's root ({to_ref}); anchor on a block in that document with at.before/at.after"
2085 ),
2086 json!({ "to": to_ref }),
2087 ));
2088 }
2089 }
2090 }
2091 let at = self.resolve_at(&repo, args.get("at"))?;
2092 let effects = sugar::move_blocks(
2093 blocks,
2094 placement_target(parent, doc_id),
2095 at,
2096 effect_expect(arg_parent_expect(args)?),
2097 );
2098 self.apply_sugar(&repo, root.as_deref(), effects, "blocks_move")
2099 }
2100 "blocks_remove" => {
2101 let (repo, root) = self.scope(args)?;
2102 self.require_root(root.as_deref())?;
2103 let blocks: Vec<String> =
2104 require_some(name, "blocks", arg_strings(args, "blocks")?, spec)?
2105 .iter()
2106 .map(|b| self.resolve_block_ref(&repo, b))
2107 .collect::<Result<_>>()?;
2108 let effects = sugar::remove_blocks(blocks);
2109 self.apply_sugar(&repo, root.as_deref(), effects, "blocks_remove")
2110 }
2111 "blocks_split" => {
2112 let (repo, root) = self.scope(args)?;
2113 self.require_root(root.as_deref())?;
2114 let block = self.resolve_block_ref(&repo, &arg_string(args, "_id")?)?;
2115 let at: Vec<usize> = args
2116 .get("at")
2117 .and_then(Json::as_array)
2118 .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))?
2119 .iter()
2120 .map(|v| {
2121 v.as_u64()
2122 .map(|n| usize::try_from(n).unwrap_or(usize::MAX))
2123 .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))
2124 })
2125 .collect::<Result<_>>()?;
2126 let effects = sugar::split_block(&block, at);
2127 self.apply_sugar(&repo, root.as_deref(), effects, "blocks_split")
2128 }
2129 "blocks_merge" => {
2130 let (repo, root) = self.scope(args)?;
2131 self.require_root(root.as_deref())?;
2132 let blocks: Vec<String> =
2133 require_some(name, "blocks", arg_strings(args, "blocks")?, spec)?
2134 .iter()
2135 .map(|b| self.resolve_block_ref(&repo, b))
2136 .collect::<Result<_>>()?;
2137 let effects = sugar::merge_blocks(blocks, arg_str(args, "separator"));
2138 self.apply_sugar(&repo, root.as_deref(), effects, "blocks_merge")
2139 }
2140 "blocks_check" => {
2141 let (repo, root) = self.scope(args)?;
2142 self.require_root(root.as_deref())?;
2143 let blocks: Vec<String> =
2144 require_some(name, "_ids", arg_strings(args, "_ids")?, spec)?
2145 .iter()
2146 .map(|b| self.resolve_block_ref(&repo, b))
2147 .collect::<Result<_>>()?;
2148 let effects =
2153 sugar::check_blocks(&blocks, arg_bool(args, "checked")?.unwrap_or(true));
2154 self.apply_sugar(&repo, root.as_deref(), effects, "blocks_check")
2155 }
2156 "nodes_set" => {
2157 let (repo, root) = self.scope(args)?;
2158 self.require_root(root.as_deref())?;
2159 let node_id = arg_string(args, "_id")?;
2160 let known: Option<i64> = self
2161 .store
2162 .conn()
2163 .query_row(
2164 "SELECT 1 FROM nodes WHERE node_id = ?1 AND repo_id = ?2",
2165 params![node_id, repo],
2166 |r| r.get(0),
2167 )
2168 .optional()?;
2169 if known.is_none() {
2170 return Err(SurfaceError::with_data(
2171 "block_missing",
2172 format!("no node with id {}", Json::String(node_id.clone())),
2173 json!({ "_id": node_id }),
2174 ));
2175 }
2176 let effects = sugar::set_node(
2177 &node_id,
2178 &arg_string(args, "prop")?,
2179 &arg_string(args, "value")?,
2180 );
2181 self.apply_sugar(&repo, root.as_deref(), effects, "nodes_set")
2182 }
2183 "blocks_append" => {
2184 let (repo, root) = self.scope(args)?;
2185 self.require_root(root.as_deref())?;
2186 let given = arg_string(args, "_id")?;
2187 let block = self.resolve_block_ref(&repo, &given)?;
2188 let text = arg_string(args, "text")?;
2189 type AppendRow = (String, Option<String>, i64, Option<String>, String);
2193 let row: Option<AppendRow> = self
2194 .store
2195 .conn()
2196 .query_row(
2197 "SELECT b.type, d.format, (SELECT count(*) FROM blocks c WHERE c.parent_block = b.block_id AND c.deleted_commit IS NULL), b.parent_block, d.path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ?1 AND b.deleted_commit IS NULL",
2198 params![block],
2199 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
2200 )
2201 .optional()?;
2202 let Some((kind, format, children, parent, doc_path)) = row else {
2203 return Err(block_missing(&given));
2204 };
2205 let container = if format.as_deref().unwrap_or("markdown") == "markdown" {
2206 kind.parse::<BlockKind>().is_ok_and(|k| k.is_container())
2207 } else {
2208 children > 0
2209 };
2210 let into = if kind == "heading" {
2211 EffectTarget::Section { heading_id: block }
2212 } else if container {
2213 EffectTarget::Block { id: block }
2214 } else {
2215 return Err(SurfaceError::with_data(
2216 "block_not_container",
2217 format!(
2218 "block {block} is a {kind}, not a heading or a container; append means into — insert after it with blocks_insert {{ to: <its parent>, at: {{ after: {} }} }}",
2219 Json::String(block.clone())
2220 ),
2221 json!({
2222 "_id": block,
2223 "_type": kind,
2224 "fix": fixes::append_after(
2227 parent.as_deref().unwrap_or(&reference_path(&doc_path)),
2228 &block,
2229 &text,
2230 ),
2231 }),
2232 ));
2233 };
2234 let effects = sugar::insert(into, At::End, &text, None);
2235 self.apply_sugar(&repo, root.as_deref(), effects, "blocks_append")
2236 }
2237 "docs_append" => {
2238 let (repo, root) = self.scope(args)?;
2239 self.require_root(root.as_deref())?;
2240 let doc_id = self.locate_doc(&repo, name, args)?;
2241 let text = arg_string(args, "text")?;
2242 let into = match arg_str(args, "section") {
2245 Some(section) => {
2246 let info = read::find_doc_by_id(self.store.conn(), &doc_id)?
2247 .ok_or_else(|| block_missing(&doc_id))?;
2248 let heading = self.resolve_section_heading(
2249 &doc_id,
2250 &info.path,
2251 section,
2252 (arg_str(args, "_id"), arg_str(args, "_path"), &text),
2253 )?;
2254 EffectTarget::Section {
2255 heading_id: heading,
2256 }
2257 }
2258 None => EffectTarget::Document {
2259 id: Some(doc_id),
2260 path: None,
2261 },
2262 };
2263 let effects = sugar::insert(into, At::End, &text, None);
2264 self.apply_sugar(&repo, root.as_deref(), effects, "docs_append")
2265 }
2266 "links_retarget" | "links_repair" => {
2267 let (repo, root) = self.scope(args)?;
2268 self.require_root(root.as_deref())?;
2269 let repairs: Vec<omgbase_store::LinkRepair> = if name == "links_retarget" {
2270 vec![omgbase_store::LinkRepair {
2271 from: arg_string(args, "from_target")?,
2272 to: arg_string(args, "to_target")?,
2273 }]
2274 } else if let Some(list) = args.get("repairs").and_then(Json::as_array) {
2275 list.iter()
2276 .map(|r| {
2277 Ok(omgbase_store::LinkRepair {
2278 from: arg_string(r, "from")?,
2279 to: arg_string(r, "to")?,
2280 })
2281 })
2282 .collect::<Result<_>>()?
2283 } else if let (Some(f), Some(t)) =
2284 (arg_str(args, "from_target"), arg_str(args, "to_target"))
2285 {
2286 vec![omgbase_store::LinkRepair {
2287 from: f.to_owned(),
2288 to: t.to_owned(),
2289 }]
2290 } else {
2291 Vec::new()
2292 };
2293 if repairs.is_empty() {
2294 return Err(SurfaceError::new(
2295 "target_missing",
2296 "links_repair requires `repairs` (array of {from,to}) or a `from_target`+`to_target` pair",
2297 ));
2298 }
2299 let path_glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
2304 let plan = self.store.links_repair(&repo, &repairs, path_glob)?;
2305 let pairs: Vec<(String, String)> = repairs
2306 .iter()
2307 .map(|r| (r.from.clone(), r.to.clone()))
2308 .collect();
2309 let effects = sugar::retarget(&pairs, path_glob);
2310 let out =
2311 self.apply_sugar_with(&repo, root.as_deref(), name, |_, _| Ok(effects))?;
2312 let mut m = Map::new();
2313 m.insert(
2314 "hits".to_owned(),
2315 Json::Array(plan.hits.iter().map(|h| json!({ "_id": h.block, "_path": reference_path(&h.path), "oldRaw": h.old_raw, "newRaw": h.new_raw })).collect()),
2316 );
2317 m.insert(
2318 "pairs".to_owned(),
2319 Json::Array(
2320 plan.pairs
2321 .iter()
2322 .map(|p| json!({ "from": p.from, "to": p.to, "hits": p.hits }))
2323 .collect(),
2324 ),
2325 );
2326 Ok(merge(m, sugar_json(&out)))
2327 }
2328 "links_stale" => {
2329 let (repo, _) = self.scope(args)?;
2330 let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
2331 if arg_bool(args, "summary")?.unwrap_or(false) {
2332 return links::links_stale_summary(&self.store, &repo, glob);
2333 }
2334 links::links_stale(&self.store, &repo, glob, arg_i64(args, "limit")?)
2335 }
2336 "docs_create" => {
2337 let (repo, root) = self.scope(args)?;
2338 let fm = arg_object(args, "frontmatter")?.cloned();
2339 let sugar::Sugar { effects, reason } = sugar::create_document(
2340 &arg_string(args, "path")?,
2341 &arg_string(args, "markdown")?,
2342 fm.as_ref(),
2343 );
2344 let rel = canonical(&arg_string(args, "path")?);
2345 let out = self.apply_sugar_with(&repo, root.as_deref(), &reason, |store, ds| {
2349 if store.live_doc_at(&repo, &rel)? {
2350 return Err(path_taken(format!("document already exists at {rel}")));
2351 }
2352 if ds.exists(&rel) {
2353 return Err(path_taken(format!("file already exists on disk at {rel}")));
2354 }
2355 Ok(effects)
2356 })?;
2357 Ok(sugar_json(&out))
2358 }
2359 "docs_move" => {
2360 let (repo, root) = self.scope(args)?;
2361 let doc = self.locate_doc(&repo, name, args)?;
2362 let to_rel = canonical(&arg_string(args, "to_path")?);
2363 let retarget = arg_bool(args, "retarget_inbound")?.unwrap_or(true);
2365 let info = self.doc_info(&repo, &doc)?;
2366 let sugar::Sugar { effects, reason } =
2367 sugar::move_document(&info.doc_id, &info.path, &to_rel, retarget);
2368 let mut report: Option<(Vec<omgbase_store::InboundLink>, Option<Retargeted>)> =
2372 None;
2373 let out = self.apply_sugar_with(&repo, root.as_deref(), &reason, |store, ds| {
2374 if store.live_doc_at(&repo, &to_rel)? {
2375 return Err(path_taken(format!("a document already exists at {to_rel}")));
2376 }
2377 let inbound = inbound_links_to(store.conn(), &repo, &info.doc_id, &info.path)?;
2378 if ds.exists(&to_rel) {
2379 return Err(path_taken(format!(
2380 "file already exists on disk at {to_rel}"
2381 )));
2382 }
2383 report = Some(if retarget && !inbound.is_empty() {
2384 let p = store.plan_inbound_retarget(
2385 &info.doc_id,
2386 &info.path,
2387 &to_rel,
2388 &inbound,
2389 )?;
2390 (
2391 p.dangling,
2392 Some(Retargeted {
2393 blocks: p.blocks,
2394 docs: p.docs,
2395 }),
2396 )
2397 } else {
2398 (inbound, None)
2399 });
2400 Ok(effects)
2401 })?;
2402 let (dangling, retargeted) = report.expect("set before the effects were built");
2403 if out.1.is_none() {
2404 return Ok(sugar_json(&out));
2406 }
2407 let mut m = Map::new();
2413 m.insert(
2414 "retargeted".to_owned(),
2415 retargeted_json(retargeted.as_ref()),
2416 );
2417 m.insert(
2418 "dangling".to_owned(),
2419 Json::Array(dangling.iter().map(dangling_json).collect()),
2420 );
2421 let Json::Object(receipt) = sugar_json(&out) else {
2422 unreachable!("a receipt is an object")
2423 };
2424 Ok(merge(receipt, Json::Object(m)))
2425 }
2426 "docs_delete" => {
2427 let (repo, root) = self.scope(args)?;
2428 let doc = self.locate_doc(&repo, name, args)?;
2429 let info = self.doc_info(&repo, &doc)?;
2430 let sugar::Sugar { effects, reason } =
2431 sugar::delete_document(&info.doc_id, &info.path);
2432 self.apply_sugar(&repo, root.as_deref(), effects, &reason)
2433 }
2434 "docs_set_meta" => {
2435 let (repo, root) = self.scope(args)?;
2436 let doc = self.locate_doc(&repo, name, args)?;
2437 let set = arg_object(args, "set")?.cloned();
2438 let unset = if args.get("unset").is_some() {
2439 arg_strings(args, "unset")?
2440 } else {
2441 Vec::new()
2442 };
2443 let info = self.doc_info(&repo, &doc)?;
2444 let sugar::Sugar { effects, reason } =
2447 sugar::set_meta(&info.doc_id, &info.path, set.as_ref(), &unset);
2448 if effects.is_empty() {
2449 return Err(argument_error(
2454 name,
2455 "pass `set` and/or `unset`",
2456 &spec.input_schema,
2457 ));
2458 }
2459 self.apply_sugar(&repo, root.as_deref(), effects, &reason)
2460 }
2461 "docs_update" => {
2462 let (repo, root) = self.scope(args)?;
2468 self.require_root(root.as_deref())?;
2469 let doc = self.locate_doc(&repo, name, args)?;
2470 let info = self.doc_info(&repo, &doc)?;
2471 let content = arg_string(args, "content")?;
2472 let reason = arg_str(args, "reason").map(str::to_owned);
2473 let origin = ApplyOrigin::new(&self.actor, reason.as_deref());
2474 let ctx = self.plan_ctx(&repo);
2475 let effects = sugar::update_content(&info.doc_id, &content);
2476 let preview = self.preview;
2477 let (plan, receipt) = self.with_writes(root.as_deref(), |store, ds| {
2478 if preview {
2479 return Ok((store.plan_effects(&ctx, &effects, ds)?, None));
2480 }
2481 let (plan, receipt) = store
2482 .apply_effects_detailed(&ctx, &effects, &origin, ds)
2483 .map_err(strip_effect_index)?;
2484 Ok((plan, Some(receipt)))
2485 })?;
2486 self.last_plan = Some(plan.clone());
2487 if receipt.as_ref().is_some_and(|r| r.commit.is_some()) {
2488 self.notify();
2489 }
2490 Ok(sugar_json(&(plan, receipt)))
2491 }
2492 "observe" => {
2493 let (repo, _) = self.scope(args)?;
2494 let path = storage_path(&arg_string(args, "path")?).to_owned();
2495 let content = arg_string(args, "content")?;
2496 let ts = self.now();
2497 let config = self.config.clone();
2498 let out = self
2499 .store
2500 .observe_one(&repo, &path, &content, &ts, &config)?;
2501 self.store.sweep_pool(&ts)?;
2502 self.notify();
2503 Ok(observe_json(&out))
2504 }
2505 "observe_many" => {
2506 let (repo, _) = self.scope(args)?;
2507 let files = args
2508 .get("files")
2509 .and_then(Json::as_array)
2510 .ok_or_else(|| bad_args("`files` must be an array of {path, content}"))?;
2511 let items: Vec<omgbase_store::BatchItem> = files
2512 .iter()
2513 .map(|f| {
2514 Ok(omgbase_store::BatchItem::observed(
2515 storage_path(&arg_string(f, "path")?),
2516 &arg_string(f, "content")?,
2517 ))
2518 })
2519 .collect::<Result<_>>()?;
2520 let ts = self.now();
2521 let config = self.config.clone();
2522 let outcomes = self.store.observe_batch(&repo, &items, &ts, &config)?;
2523 self.store.sweep_pool(&ts)?;
2524 self.notify();
2525 let mut out = Vec::with_capacity(outcomes.len());
2526 for o in &outcomes {
2527 match o.as_observed() {
2528 Some(obs) => out.push(observe_json(obs)),
2529 None => {
2530 return Err(SurfaceError::other(format!(
2531 "observe_many: unexpected outcome for {}",
2532 o.path()
2533 )));
2534 }
2535 }
2536 }
2537 Ok(Json::Array(out))
2538 }
2539 "observe_delete" => {
2540 let (repo, _) = self.scope(args)?;
2541 let path = storage_path(&arg_string(args, "path")?).to_owned();
2542 let ts = self.now();
2543 let out = self.store.observe_delete(&repo, &path, &ts)?;
2544 self.notify();
2545 Ok(
2546 json!({ "_id": out.doc_id, "_path": reference_path(&out.path), "deleted": out.deleted() }),
2547 )
2548 }
2549 "blocks_history" => history::blocks_history(
2550 &self.store,
2551 &arg_string(args, "_id")?,
2552 arg_i64(args, "limit")?,
2553 ),
2554 "docs_diff" => {
2555 let (repo, _) = self.scope(args)?;
2556 let doc_id = self.locate_doc(&repo, name, args)?;
2557 history::diff_blocks(
2558 &self.store,
2559 &doc_id,
2560 &arg_string(args, "from_rev")?,
2561 &arg_string(args, "to_rev")?,
2562 )
2563 }
2564 "docs_diff_unified" => {
2565 let (repo, _) = self.scope(args)?;
2566 let doc_ref = arg_str(args, "_id")
2567 .or(arg_str(args, "_path"))
2568 .unwrap_or_default()
2569 .to_owned();
2570 let doc_id = self.locate_doc(&repo, name, args)?;
2571 let revs = history::recent_revs(self.store.conn(), &doc_id)?;
2572 let to_rev = arg_str(args, "to_rev")
2573 .map(str::to_owned)
2574 .or_else(|| revs.first().cloned());
2575 let from_rev = arg_str(args, "from_rev")
2576 .map(str::to_owned)
2577 .or_else(|| revs.get(1).cloned())
2578 .or_else(|| revs.first().cloned());
2579 let (Some(from), Some(to)) = (from_rev, to_rev) else {
2580 return Err(SurfaceError::new(
2581 "target_missing",
2582 format!("no revisions to diff for {}", Json::String(doc_ref)),
2583 ));
2584 };
2585 let path: Option<String> = self
2586 .store
2587 .conn()
2588 .query_row(
2589 "SELECT path FROM docs WHERE doc_id = ?1",
2590 params![doc_id],
2591 |r| r.get(0),
2592 )
2593 .optional()?;
2594 let diff = history::diff_unified_text(&self.store, &doc_id, &from, &to)?;
2595 Ok(
2596 json!({ "_id": doc_id, "_path": path.as_deref().map_or_else(String::new, reference_path), "from": from, "to": to, "diff": diff }),
2597 )
2598 }
2599 "docs_read_at" => {
2602 let (repo, _) = self.scope(args)?;
2603 let doc_id = self.locate_doc(&repo, name, args)?;
2604 let rev = arg_string(args, "rev")?;
2605 self.docs_read_at(&doc_id, &rev)
2606 }
2607 "docs_history" => {
2608 let (repo, _) = self.scope(args)?;
2609 let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
2610 let id = arg_str(args, "_id");
2611 let path = arg_str(args, "_path");
2612 if glob.is_none() && id.is_none() && path.is_none() {
2613 return Err(SurfaceError::new(
2614 "target_missing",
2615 "docs_history requires one of path_glob, _id or _path",
2616 ));
2617 }
2618 let include_deleted = arg_bool(args, "include_deleted")?.unwrap_or(false);
2619 let doc: Option<String> = if id.is_some() || path.is_some() {
2622 let tomb: Option<String> = if include_deleted {
2623 match (id, path) {
2624 (Some(i), _) => self
2625 .store
2626 .conn()
2627 .query_row(
2628 "SELECT doc_id FROM docs WHERE doc_id = ?1 AND repo_id = ?2",
2629 params![i, repo],
2630 |r| r.get(0),
2631 )
2632 .optional()?,
2633 (None, Some(p)) => self
2634 .store
2635 .conn()
2636 .query_row(
2637 "SELECT doc_id FROM docs WHERE repo_id = ?1 AND path = ?2",
2638 params![repo, storage_path(p)],
2639 |r| r.get(0),
2640 )
2641 .optional()?,
2642 (None, None) => None,
2643 }
2644 } else {
2645 None
2646 };
2647 Some(match tomb {
2648 Some(d) => d,
2649 None => self.locate_doc(&repo, name, args)?,
2650 })
2651 } else {
2652 None
2653 };
2654 history::docs_history(
2655 &self.store,
2656 &repo,
2657 glob,
2658 doc.as_deref(),
2659 include_deleted,
2660 arg_i64(args, "limit")?,
2661 )
2662 }
2663 "changes_since" => {
2664 let (repo, _) = self.scope(args)?;
2665 let page = self.store.changes_since(
2666 &repo,
2667 arg_i64(args, "cursor")?.unwrap_or(0),
2668 arg_usize(args, "limit")?.unwrap_or(50),
2669 arg_str(args, "origin").filter(|o| !o.is_empty()),
2670 )?;
2671 Ok(root_changes_page(page.to_json()))
2672 }
2673 "repos_status" => {
2674 let (repo, root) = self.scope(args)?;
2675 let fs = RealFileSystem;
2676 let disk = root
2677 .as_deref()
2678 .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
2679 Ok(omgbase_sync::repos_status(&self.store, &repo, disk)?.to_json())
2680 }
2681 "sync_status" => {
2682 let (repo, root) = self.scope(args)?;
2683 let fs = RealFileSystem;
2684 let disk = root
2685 .as_deref()
2686 .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
2687 Ok(omgbase_sync::sync_status(&self.store, &repo, disk)?.to_json())
2688 }
2689 "repos" => {
2690 let rows = self.repo_rows()?;
2691 Ok(json!({
2692 "repos": rows.iter().map(|r| json!({ "slug": r.slug, "hasSource": r.root_path.is_some() })).collect::<Vec<_>>(),
2693 }))
2694 }
2695 "version" => version_info(Some(&self.store), &self.build_info, self.profile),
2697 other => Err(SurfaceError::other(format!("unknown tool {other}"))),
2698 }
2699 }
2700}
2701
2702fn block_missing(value: &str) -> SurfaceError {
2705 SurfaceError::with_data(
2706 "block_missing",
2707 format!("no block with id {}", Json::String(value.to_owned())),
2708 json!({ "_id": value }),
2709 )
2710}
2711
2712fn node_has_no_block(node_id: &str, doc_id: &str) -> SurfaceError {
2714 SurfaceError::with_data(
2715 "block_missing",
2716 format!("node {} has no block", Json::String(node_id.to_owned())),
2717 json!({ "_id": node_id, "_doc_id": doc_id }),
2718 )
2719}
2720
2721pub const PROVIDER_UNAVAILABLE_MESSAGE: &str = "semantic(...) needs an embedding provider; none is configured — set embedding.provider (omg config set embedding.provider <command|url>) and embed the repository (omg embed drain)";
2723
2724#[must_use]
2726pub fn provider_unavailable() -> SurfaceError {
2727 SurfaceError::with_data(
2728 "provider_unavailable",
2729 PROVIDER_UNAVAILABLE_MESSAGE,
2730 json!({ "provider": null }),
2731 )
2732}
2733
2734fn parse_effects(v: &Json) -> Result<Vec<PublicEffect>> {
2737 PublicEffect::list_from_json(v).map_err(|m| SurfaceError::filter_invalid(m, "arguments"))
2738}
2739
2740#[must_use]
2745pub fn plan_json(plan: &Plan) -> Json {
2746 let mut v = plan.to_json(true);
2747 if let Some(preview) = v.get_mut("preview").and_then(Json::as_object_mut) {
2748 if let Some(revs) = preview.get_mut("revisions").and_then(Json::as_array_mut) {
2749 for r in revs {
2750 root_field(r, "_path");
2751 }
2752 }
2753 if let Some(results) = preview.get_mut("results").and_then(Json::as_array_mut) {
2757 for r in results {
2758 if let Some(o) = r.as_object_mut() {
2759 if let Some(m) = o.shift_remove("merged_into") {
2760 o.insert("mergedInto".to_owned(), m);
2761 }
2762 }
2763 }
2764 }
2765 if let Some(diffs) = preview.get_mut("diffs") {
2766 *diffs = reference_keyed(diffs);
2767 }
2768 if let Some(retargets) = preview.get_mut("retarget").and_then(Json::as_array_mut) {
2770 for r in retargets {
2771 if let Some(hits) = r.get_mut("hits").and_then(Json::as_array_mut) {
2772 for h in hits {
2773 root_field(h, "_path");
2774 }
2775 }
2776 }
2777 }
2778 }
2779 v
2780}
2781
2782#[must_use]
2784pub fn receipt_json(receipt: &Receipt) -> Json {
2785 let mut v = receipt.to_json();
2786 if let Some(revs) = v.get_mut("revisions").and_then(Json::as_array_mut) {
2787 for r in revs {
2788 root_field(r, "_path");
2789 }
2790 }
2791 v
2792}
2793
2794fn dangling_json(l: &omgbase_store::InboundLink) -> Json {
2798 let mut m = Map::new();
2799 m.insert("_doc_id".to_owned(), json!(l.doc));
2800 m.insert("_path".to_owned(), json!(reference_path(&l.path)));
2801 m.insert("_block_id".to_owned(), json!(l.block));
2802 m.insert("target".to_owned(), json!(l.target));
2803 m.insert("_anchor".to_owned(), json!(l.anchor));
2804 if let Some(f) = &l.field {
2805 m.insert("_field".to_owned(), json!(f));
2806 }
2807 Json::Object(m)
2808}
2809
2810fn path_taken(message: String) -> SurfaceError {
2813 SurfaceError::from(MutationError::new(ErrorCode::PathTaken, message))
2814}
2815
2816fn root_changes_page(mut page: Json) -> Json {
2826 let Some(digests) = page.get_mut("digests").and_then(Json::as_array_mut) else {
2827 return page;
2828 };
2829 for d in digests.iter_mut() {
2830 if let Some(revs) = d.get_mut("revisions").and_then(Json::as_array_mut) {
2831 for r in revs.iter_mut() {
2832 root_field(r, "_path");
2833 }
2834 }
2835 if let Some(summary) = d.get("summary").and_then(Json::as_str) {
2836 let rooted = root_summary(summary);
2837 d["summary"] = Json::String(rooted);
2838 }
2839 }
2840 page
2841}
2842
2843fn root_summary(summary: &str) -> String {
2845 let Some(colon) = summary.find(": ") else {
2846 return summary.to_owned();
2847 };
2848 let (head, rest) = summary.split_at(colon + 2);
2849 let (subject, detail) = match rest.find(" — ") {
2850 Some(i) => rest.split_at(i),
2851 None => (rest, ""),
2852 };
2853 let root_list = |s: &str| -> String {
2854 if s.is_empty() {
2855 return String::new();
2856 }
2857 s.split(", ")
2858 .map(reference_path)
2859 .collect::<Vec<_>>()
2860 .join(", ")
2861 };
2862 let subject = if let Some(paths) = subject.strip_prefix("deleted ") {
2863 format!("deleted {}", root_list(paths))
2864 } else if let Some(mv) = subject.strip_prefix("moved ") {
2865 let (mv, rest) = match mv.split_once("; ") {
2867 Some((mv, rest)) => (mv, Some(rest)),
2868 None => (mv, None),
2869 };
2870 let moved = match mv.split_once(" → ") {
2871 Some((from, to)) => format!("moved {} → {}", reference_path(from), reference_path(to)),
2872 None => format!("moved {mv}"),
2873 };
2874 match rest {
2875 Some(rest) => format!("{moved}; {}", root_list(rest)),
2876 None => moved,
2877 }
2878 } else {
2879 root_list(subject)
2880 };
2881 format!("{head}{subject}{detail}")
2882}
2883
2884fn evidence_json(e: &omgbase_store::Evidence) -> Json {
2887 let mut m = Map::new();
2888 m.insert("rrf".to_owned(), json!(e.rrf));
2889 m.insert("boosts".to_owned(), e.boosts.to_json());
2890 if let Some(r) = e.fts_rank {
2891 m.insert("ftsRank".to_owned(), json!(r));
2892 }
2893 if let Some(r) = e.vector_rank {
2894 m.insert("vectorRank".to_owned(), json!(r));
2895 if let Some(c) = e.cosine {
2896 m.insert("cosine".to_owned(), json!(c));
2897 }
2898 }
2899 Json::Object(m)
2900}
2901
2902fn observe_json(o: &omgbase_store::ObserveOutcome) -> Json {
2907 json!({
2908 "_id": o.doc_id,
2909 "_path": reference_path(&o.path),
2910 "rev": o.rev,
2911 "commitId": o.commit_id,
2912 "converged": o.converged,
2913 "echo": o.echo,
2914 "conflicted": o.conflicted,
2915 "dispositions": o.dispositions.iter().map(|(k, n)| json!({ "kind": k, "count": n })).collect::<Vec<_>>(),
2916 })
2917}
2918
2919#[cfg(test)]
2920mod sugar_tests;
2921
2922#[cfg(test)]
2923mod tests {
2924 use super::*;
2925 use omgbase_store::{MemDocStore, SequentialMinter};
2926
2927 fn surface() -> Surface {
2928 let mut store =
2929 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
2930 let repo = store.create_repo("fixture").unwrap();
2931 let mut n = 0;
2932 Surface::new(store, &repo, None)
2933 .with_doc_store(Box::new(MemDocStore::new()))
2934 .with_clock(move || {
2935 n += 1;
2936 format!("2026-09-26T10:{n:02}:00.000Z")
2937 })
2938 }
2939
2940 #[test]
2941 fn catalog_lists_every_tool_of_the_table() {
2942 let names: Vec<&str> = tools().iter().map(|t| t.name).collect();
2943 for want in [
2944 "docs_outline",
2945 "docs_read",
2946 "docs_read_many",
2947 "blocks_read",
2948 "nodes_block_read",
2949 "blocks_read_many",
2950 "docs_tree",
2951 "docs_list",
2952 "query_syntax",
2953 "query",
2954 "graph",
2955 "blocks_search",
2956 "blocks_resolve",
2957 "apply",
2958 "plan",
2959 "execute",
2960 "blocks_insert",
2961 "blocks_update",
2962 "blocks_move",
2963 "blocks_remove",
2964 "blocks_split",
2965 "blocks_merge",
2966 "blocks_check",
2967 "nodes_set",
2968 "blocks_append",
2969 "docs_append",
2970 "links_retarget",
2971 "links_stale",
2972 "links_repair",
2973 "docs_create",
2974 "docs_move",
2975 "docs_delete",
2976 "docs_set_meta",
2977 "docs_update",
2978 "observe",
2979 "observe_many",
2980 "observe_delete",
2981 "blocks_history",
2982 "docs_diff",
2983 "docs_diff_unified",
2984 "docs_read_at",
2985 "docs_history",
2986 "changes_since",
2987 "repos_status",
2988 "sync_status",
2989 "repos",
2990 "version",
2991 ] {
2992 assert!(names.contains(&want), "missing {want}");
2993 }
2994 assert_eq!(names.len(), 47);
2995 for t in tools() {
2996 assert_eq!(t.input_schema["type"], "object");
2997 }
2998 }
2999
3000 #[test]
3004 fn profiles_filter_the_listing_not_the_semantics() {
3005 let full = surface();
3006 assert_eq!(full.profile(), Profile::Full);
3007 assert_eq!(full.tools(), tools());
3008 let minimal = surface().with_profile(Profile::Minimal);
3009 assert_eq!(minimal.profile(), Profile::Minimal);
3010 let names: Vec<&str> = minimal.tools().iter().map(|t| t.name).collect();
3011 let mut sorted_names = names.clone();
3013 sorted_names.sort_unstable();
3014 let mut sorted_set = MINIMAL_TOOLS.to_vec();
3015 sorted_set.sort_unstable();
3016 assert_eq!(sorted_names, sorted_set);
3017 assert_eq!(
3018 names,
3019 [
3020 "docs_read",
3021 "docs_tree",
3022 "query_syntax",
3023 "query",
3024 "blocks_resolve",
3025 "apply",
3026 "plan",
3027 "execute",
3028 "changes_since",
3029 "version",
3030 ]
3031 );
3032 for t in minimal.tools() {
3033 assert_eq!(
3034 Some(&t),
3035 tool_spec(t.name),
3036 "{} is the full table's spec",
3037 t.name
3038 );
3039 }
3040 assert!(
3041 Surface::is_write_tool("docs_create"),
3042 "the write set is static"
3043 );
3044 assert_eq!("full".parse::<Profile>(), Ok(Profile::Full));
3045 assert_eq!("minimal".parse::<Profile>(), Ok(Profile::Minimal));
3046 assert_eq!(
3047 "lite".parse::<Profile>(),
3048 Err("unknown tools profile \"lite\" (expected full|minimal)".to_owned())
3049 );
3050 assert_eq!(Profile::Minimal.to_string(), "minimal");
3051 let mut minimal = minimal;
3055 let v = minimal.call("version", json!({}));
3056 assert_eq!(v.body["profile"], "minimal");
3057 let mut full = full;
3058 assert_eq!(full.call("version", json!({})).body["profile"], "full");
3059 let listed = minimal.call("docs_list", json!({}));
3060 assert!(!listed.is_error);
3061 let unknown = minimal.call("docs_lits", json!({}));
3062 assert!(unknown.is_error);
3063 assert_eq!(unknown.body["message"], "unknown tool docs_lits");
3064 }
3065
3066 #[test]
3072 fn plan_apply_and_execute_take_a_selector() {
3073 let mut s = surface();
3074 for (path, content) in [
3075 ("hub.md", "---\ntype: project\n---\n\n# Hub\n\nIntro.\n"),
3076 (
3077 "ws/alpha.md",
3078 "---\ntype: workstream\nstatus: completed\nproject: hub.md\n---\n\n# Alpha\n",
3079 ),
3080 (
3081 "ws/beta.md",
3082 "---\ntype: workstream\nstatus: completed\nproject: hub.md\n---\n\n# Beta\n",
3083 ),
3084 (
3085 "ws/gamma.md",
3086 "---\ntype: workstream\nstatus: active\nproject: hub.md\n---\n\n# Gamma\n",
3087 ),
3088 ] {
3089 let out = s.call("observe", json!({ "path": path, "content": content }));
3090 assert!(!out.is_error, "{}", out.body);
3091 }
3092 let select = "from docs where type == \"workstream\" and status == \"completed\"";
3093 let templates = json!([
3094 { "op": "insert", "into": { "kind": "document", "_path": { "oqx": "project" } }, "markdown": { "oqx": "\"- [\" + _title + \"](\" + _path + \")\"" } },
3095 { "op": "set", "field": "_frontmatter.rolled_up", "value": true },
3096 ]);
3097 let plan = s.call(
3098 "plan",
3099 json!({ "select": select, "effects": templates, "diffs": true }),
3100 );
3101 assert!(!plan.is_error, "{}", plan.body);
3102 let plan = plan.body;
3103 assert_eq!(plan["planner_v"], "p3");
3104 assert_eq!(plan["effects"].as_array().map(Vec::len), Some(4));
3105 assert_eq!(
3106 plan["effects"][0]["into"],
3107 json!({ "kind": "document", "_id": "d_0", "_path": "hub.md" })
3108 );
3109 assert_eq!(plan["effects"][0]["markdown"], "- [Alpha](/ws/alpha.md)");
3110 assert_eq!(
3111 plan["effects"][1]["target"],
3112 json!({ "kind": "document", "_id": "d_1", "_path": "ws/alpha.md" })
3113 );
3114 assert_eq!(
3115 plan["selection"],
3116 json!({ "query": select, "templates": templates, "cardinality": 2, "identities": ["d_1", "d_2"] })
3117 );
3118 assert!(
3119 plan["preview"]["diffs"]["/hub.md"]["after"]
3120 .as_str()
3121 .unwrap()
3122 .contains("- [Beta](/ws/beta.md)")
3123 );
3124 let receipt = s.call(
3126 "execute",
3127 json!({ "plan": plan.clone(), "reason": "rollup" }),
3128 );
3129 assert!(!receipt.is_error, "{}", receipt.body);
3130 assert_eq!(receipt.body["plan_id"], plan["plan_id"]);
3131 assert_eq!(receipt.body["revisions"].as_array().map(Vec::len), Some(3));
3132 let ops: String = s
3133 .store()
3134 .conn()
3135 .query_row(
3136 "SELECT ops FROM commits WHERE commit_id = ?1",
3137 [receipt.body["commit"].as_str().unwrap()],
3138 |r| r.get(0),
3139 )
3140 .unwrap();
3141 assert!(ops.contains("\"selection\":{\"cardinality\":2"), "{ops}");
3142 let again = s.call(
3146 "plan",
3147 json!({ "select": select, "effects": templates, "expect": { "max": 5 } }),
3148 );
3149 assert!(!again.is_error, "{}", again.body);
3150 assert_eq!(again.body["selection"]["expect"], json!({ "max": 5 }));
3151 let out = s.call("observe", json!({ "path": "ws/delta.md", "content": "---\ntype: workstream\nstatus: completed\nproject: hub.md\n---\n\n# Delta\n" }));
3152 assert!(!out.is_error);
3153 let stale = s.call("execute", json!({ "plan_id": again.body["plan_id"], "select": select, "effects": templates, "expect": { "max": 5 } }));
3154 assert!(stale.is_error);
3155 assert_eq!(stale.body["error"], "plan_mismatch", "{}", stale.body);
3156 assert_eq!(stale.body["data"]["expected"], again.body["plan_id"]);
3157 let unbounded = s.call("apply", json!({ "select": select, "effects": templates }));
3159 assert!(unbounded.is_error);
3160 assert_eq!(unbounded.body["error"], "filter_invalid");
3161 assert_eq!(unbounded.body["data"]["hint"], "selection");
3162 assert_eq!(
3163 unbounded.body["message"],
3164 "`expect.max` is required on `apply` with `select` — bound the selection, or preview it with `plan`"
3165 );
3166 let small = s.call(
3167 "apply",
3168 json!({ "select": select, "effects": templates, "expect": { "max": 2 } }),
3169 );
3170 assert!(small.is_error);
3171 assert_eq!(small.body["error"], "selection_too_large", "{}", small.body);
3172 assert_eq!(
3175 small.body["data"],
3176 json!({
3177 "cardinality": 3,
3178 "max": 2,
3179 "fix": omgbase_mutate::canonical_json(&json!({ "select": select, "effects": templates, "expect": { "max": 3 } })),
3180 })
3181 );
3182 let one = s.call(
3183 "apply",
3184 json!({ "select": select, "effects": templates, "expect": { "max": 1 } }),
3185 );
3186 assert_eq!(
3189 one.body["data"],
3190 json!({
3191 "cardinality": ">1",
3192 "max": 1,
3193 "fix": omgbase_mutate::canonical_json(&json!({ "select": select, "effects": templates, "expect": { "max": 3 } })),
3194 }),
3195 "{}",
3196 one.body
3197 );
3198 let none = s.call("apply", json!({ "select": "from docs where type == \"nope\"", "effects": templates, "expect": { "max": 10 } }));
3200 assert!(!none.is_error, "{}", none.body);
3201 assert_eq!(none.body["commit"], Json::Null);
3202 assert_eq!(none.body["revisions"], json!([]));
3203 let empty = s.call(
3204 "plan",
3205 json!({ "select": "from docs where type == \"nope\"", "effects": templates }),
3206 );
3207 assert_eq!(empty.body["effects"], json!([]));
3208 assert_eq!(empty.body["selection"]["cardinality"], 0);
3209 let e = s.call(
3211 "plan",
3212 json!({ "effects": templates, "expect": { "max": 1 } }),
3213 );
3214 assert_eq!(
3215 e.body["message"],
3216 "`expect` bounds a selection — pass `select` with it"
3217 );
3218 let e = s.call(
3219 "plan",
3220 json!({ "select": "from links", "effects": templates }),
3221 );
3222 assert_eq!(e.body["error"], "filter_invalid");
3223 assert_eq!(e.body["data"]["hint"], "selection");
3224 assert!(
3225 e.body["message"]
3226 .as_str()
3227 .unwrap()
3228 .starts_with("select: `links` rows")
3229 );
3230 let e = s.call("execute", json!({ "plan": plan, "select": select }));
3231 assert!(
3232 e.body["message"]
3233 .as_str()
3234 .unwrap()
3235 .starts_with("pass exactly one of `plan` or `plan_id` + `effects`"),
3236 "{}",
3237 e.body
3238 );
3239 let e = s.call(
3240 "plan",
3241 json!({ "select": select, "effects": templates, "expect": 3 }),
3242 );
3243 assert!(
3244 e.body["message"]
3245 .as_str()
3246 .unwrap()
3247 .starts_with("`expect` must be"),
3248 "{}",
3249 e.body
3250 );
3251 }
3252
3253 #[test]
3258 fn plan_execute_and_apply_effects_round_trip() {
3259 let mut s = surface();
3260 let out = s.call(
3261 "observe",
3262 json!({ "path": "a.md", "content": "---\nstatus: open\n---\n\n# A\n\nAlpha.\n" }),
3263 );
3264 assert!(!out.is_error, "{}", out.body);
3265 let effects = json!([
3266 { "op": "set", "target": { "kind": "document", "_path": "/a.md" }, "field": "_frontmatter.status", "value": "done" },
3267 { "op": "insert", "into": { "kind": "section", "heading_id": "b_0" }, "markdown": "Tail." },
3268 ]);
3269 let plan = s.call("plan", json!({ "effects": effects, "diffs": true }));
3270 assert!(!plan.is_error, "{}", plan.body);
3271 let plan = plan.body;
3272 assert_eq!(plan["version"], 1);
3273 assert_eq!(plan["plan_id"].as_str().map(str::len), Some(64));
3274 assert_eq!(
3275 plan["effects"][0]["target"],
3276 json!({ "kind": "document", "_id": "d_0", "_path": "a.md" }),
3277 "the core keeps storage paths"
3278 );
3279 assert_eq!(
3280 plan["preview"]["revisions"][0]["_path"], "/a.md",
3281 "the preview is rooted"
3282 );
3283 assert!(
3284 plan["preview"]["diffs"].get("/a.md").is_some(),
3285 "{}",
3286 plan["preview"]["diffs"]
3287 );
3288 assert_eq!(
3289 plan["new_ids"],
3290 json!([{ "symbol": "$new.0", "kind": "block" }])
3291 );
3292 let dry = s.call("apply", json!({ "effects": effects, "dry_run": true }));
3295 assert_eq!(dry.body["error"], "filter_invalid", "{}", dry.body);
3296 assert_eq!(
3297 dry.body["message"],
3298 "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (apply takes effects, expect?, reason?, repo?, select?)"
3299 );
3300 let ops = s.call("apply", json!({ "ops": [] }));
3301 assert_eq!(ops.body["error"], "filter_invalid");
3302 assert_eq!(
3303 ops.body["message"],
3304 "`ops` is no longer an argument — `apply` takes `effects` (the kernel ops are a library/fixture contract since spec/mutate 3.0) (apply takes effects, expect?, reason?, repo?, select?)"
3305 );
3306 assert_eq!(ops.body["data"]["hint"], "arguments");
3307 let neither = s.call("apply", json!({}));
3308 assert_eq!(neither.body["error"], "filter_invalid");
3309 assert_eq!(
3310 neither.body["message"],
3311 "`effects` is required (apply takes effects, expect?, reason?, repo?, select?)"
3312 );
3313 let previewed = s
3316 .preview_call("apply", &json!({ "effects": effects }))
3317 .unwrap();
3318 assert_eq!(previewed["plan_id"], plan["plan_id"]);
3319 assert!(previewed.get("preview").is_some());
3320 let bad = s.call("apply", json!({ "effects": [{ "op": "nope" }] }));
3321 assert!(bad.is_error);
3322 assert_eq!(bad.body["error"], "filter_invalid");
3323 assert_eq!(bad.body["data"]["hint"], "arguments");
3324 assert!(
3325 bad.body["message"]
3326 .as_str()
3327 .unwrap()
3328 .starts_with("effects[0]:"),
3329 "{}",
3330 bad.body
3331 );
3332 let empty = s.call("execute", json!({}));
3334 assert!(empty.is_error);
3335 assert_eq!(empty.body["data"]["hint"], "arguments");
3336 let mut echoed = plan.clone();
3338 echoed.as_object_mut().unwrap().remove("preview");
3339 let receipt = s.call("execute", json!({ "plan": echoed, "reason": "slice 1" }));
3340 assert!(!receipt.is_error, "{}", receipt.body);
3341 assert_eq!(receipt.body["plan_id"], plan["plan_id"]);
3342 assert_eq!(receipt.body["committed"], true);
3343 assert_eq!(receipt.body["commit"], "c_1");
3344 assert_eq!(receipt.body["revisions"][0]["_path"], "/a.md");
3345 assert_eq!(receipt.body["new_ids"], json!({ "$new.0": "b_2" }));
3346 let read = s.call("docs_read", json!({ "_path": "a.md" }));
3347 assert_eq!(
3348 read.body["_raw"],
3349 "---\nstatus: done\n---\n\n# A\n\nAlpha.\n\nTail.\n"
3350 );
3351 let again = s.call("execute", json!({ "plan": echoed }));
3353 assert!(again.is_error);
3354 assert_eq!(again.body["error"], "stale_plan");
3355 assert_eq!(again.body["retriable"], true);
3356 let digest = s.call(
3357 "execute",
3358 json!({ "plan_id": plan["plan_id"], "effects": effects }),
3359 );
3360 assert!(digest.is_error);
3361 assert_eq!(digest.body["error"], "plan_mismatch", "{}", digest.body);
3362 let half = s.call("execute", json!({ "plan_id": plan["plan_id"] }));
3363 assert!(half.is_error);
3364 assert!(
3365 half.body["message"]
3366 .as_str()
3367 .unwrap()
3368 .starts_with("the digest form needs both"),
3369 "{}",
3370 half.body
3371 );
3372 let v2 = s.call(
3373 "execute",
3374 json!({ "plan": { "version": 2, "plan_id": "x" } }),
3375 );
3376 assert_eq!(v2.body["error"], "unsupported_plan_version", "{}", v2.body);
3377 let ops_count: i64 = s
3378 .store()
3379 .conn()
3380 .query_row(
3381 "SELECT count(*) FROM commits WHERE ops IS NOT NULL",
3382 [],
3383 |r| r.get(0),
3384 )
3385 .unwrap();
3386 assert_eq!(ops_count, 1, "the effect commit recorded its intent");
3387 assert!(Surface::is_write_tool("plan") && Surface::is_write_tool("execute"));
3388 }
3389
3390 #[test]
3393 fn bare_hits_are_the_projection_only() {
3394 let mut s = surface();
3395 for (path, content) in [
3396 ("a.md", "---\nlayer: canon\nid: authored-a\n---\n# Alpha\n"),
3397 ("b.md", "---\nlayer: canon\n---\n# Beta\n"),
3398 ("c.md", "---\nlayer: working\n---\n# Gamma\n"),
3399 ] {
3400 let out = s.call("observe", json!({ "path": path, "content": content }));
3401 assert!(!out.is_error, "{}", out.body);
3402 }
3403 let plain = s.call("query", json!({ "query": "select _title from docs" }));
3405 assert_eq!(
3406 plain.body["hits"][0],
3407 json!({ "_id": "d_0", "_path": "/a.md", "_title": "Alpha" })
3408 );
3409 let bare = s.call(
3411 "query",
3412 json!({ "query": "select _title from docs", "bare": true }),
3413 );
3414 assert!(!bare.is_error, "{}", bare.body);
3415 assert_eq!(
3416 bare.body["hits"],
3417 json!([{ "_title": "Alpha" }, { "_title": "Beta" }, { "_title": "Gamma" }])
3418 );
3419 assert_eq!(bare.body["truncated"], false);
3420 assert_eq!(bare.body["cursor"], Json::Null);
3421 assert_eq!(bare.body["consumer"], "collect");
3422 let intrinsic = s.call(
3426 "query",
3427 json!({ "query": "select i: id, _id, _path from docs where _path == \"a.md\"", "bare": true }),
3428 );
3429 assert_eq!(
3430 intrinsic.body["hits"].to_string(),
3431 r#"[{"i":"authored-a","_id":"d_0","_path":"/a.md"}]"#
3432 );
3433 let no_collision = s.call(
3434 "query",
3435 json!({ "query": "select _path: layer from docs where _title == \"Alpha\"", "bare": true }),
3436 );
3437 assert_eq!(no_collision.body["hits"], json!([{ "_path": "canon" }]));
3438 assert_eq!(
3439 s.call("query", json!({ "query": "select _path: layer from docs" }))
3440 .body["error"],
3441 "filter_invalid"
3442 );
3443 let count = s.call("query", json!({ "query": "docs count { }", "bare": true }));
3445 assert_eq!(count.body["count"], 3, "{}", count.body);
3446 let page1 = s.call(
3448 "query",
3449 json!({ "query": "select _title from docs", "bare": true, "limit": 2 }),
3450 );
3451 assert_eq!(page1.body["truncated"], true);
3452 assert_eq!(
3453 page1.body["hits"],
3454 json!([{ "_title": "Alpha" }, { "_title": "Beta" }])
3455 );
3456 let cursor = page1.body["cursor"].as_str().expect("a cursor").to_owned();
3457 let page2 = s.call(
3458 "query",
3459 json!({ "query": "select _title from docs", "bare": true, "limit": 2, "cursor": cursor }),
3460 );
3461 assert_eq!(page2.body["hits"], json!([{ "_title": "Gamma" }]));
3462 assert_eq!(page2.body["truncated"], false);
3463 let first = s.call(
3465 "query",
3466 json!({ "query": "docs first { select layer where layer == \"working\" }", "bare": true }),
3467 );
3468 assert_eq!(first.body["hits"], json!([{ "layer": "working" }]));
3469 let distinct = s.call(
3470 "query",
3471 json!({ "query": "select distinct layer from docs", "bare": true }),
3472 );
3473 assert_eq!(
3474 distinct.body["hits"],
3475 json!([{ "layer": "canon" }, { "layer": "working" }])
3476 );
3477 let nested = s.call(
3479 "query",
3480 json!({ "query": "select b: _blocks collect { } from docs where _path == \"b.md\"", "bare": true }),
3481 );
3482 assert_eq!(
3483 nested.body["hits"],
3484 json!([{ "b": [{ "_id": "b_1", "_path": "/b.md" }] }])
3485 );
3486 let no_select = s.call("query", json!({ "query": "from docs", "bare": true }));
3488 assert_eq!(no_select.body["error"], "filter_invalid");
3489 assert_eq!(
3490 no_select.body["message"],
3491 "`bare` needs a `select` — without a projection a hit is only its envelope"
3492 );
3493 let values = s.call(
3494 "query",
3495 json!({ "query": "select _title values from docs", "bare": true }),
3496 );
3497 assert_eq!(values.body["error"], "filter_invalid");
3498 assert_eq!(
3499 values.body["message"],
3500 crate::query::BARE_WITH_VALUES_MESSAGE
3501 );
3502 let typed = s.call(
3503 "query",
3504 json!({ "query": "select _title from docs", "bare": "yes" }),
3505 );
3506 assert_eq!(typed.body["error"], "filter_invalid");
3507 assert_eq!(
3508 typed.body["message"],
3509 "`bare` must be a boolean (query takes bare?, cursor?, limit?, query, repo?)"
3510 );
3511 let off = s.call(
3513 "query",
3514 json!({ "query": "select _title from docs", "bare": false }),
3515 );
3516 assert_eq!(off.body["hits"][0]["_id"], "d_0");
3517 assert!(s.tools().iter().any(
3518 |t| t.name == "query" && t.input_schema["properties"]["bare"]["type"] == "boolean"
3519 ));
3520 }
3521
3522 #[test]
3523 fn observe_read_and_query_round_trip() {
3524 let mut s = surface();
3525 let out = s.call("observe", json!({ "path": "a.md", "content": "---\nlayer: canon\n---\n# Title\n\nHello world.\n\n- [ ] task one\n" }));
3526 assert!(!out.is_error, "{}", out.body);
3527 assert_eq!(out.body["_id"], "d_0");
3528 assert_eq!(out.body["echo"], false);
3529 let read = s.call("docs_read", json!({ "_path": "a.md", "include_ids": true }));
3530 assert!(!read.is_error, "{}", read.body);
3531 assert_eq!(read.body["_path"], "/a.md");
3532 assert_eq!(
3534 s.call("docs_read", json!({ "_path": "/a.md" })).body["_path"],
3535 "/a.md"
3536 );
3537 let retired = s.call("docs_read", json!({ "doc": "a.md" }));
3540 assert_eq!(retired.body["error"], "filter_invalid");
3541 assert_eq!(
3542 retired.body["message"],
3543 "`doc` is no longer an argument — pass `_id` or `_path` (docs_read takes _id?, _path?, at?, include_ids?, repo?)"
3544 );
3545 let neither = s.call("docs_read", json!({}));
3546 assert_eq!(
3547 neither.body["message"],
3548 "pass _id or _path (docs_read takes _id?, _path?, at?, include_ids?, repo?)"
3549 );
3550 let bare_path = s.call("docs_read", json!({ "path": "a.md" }));
3551 assert_eq!(
3552 bare_path.body["message"],
3553 "`path` is not an argument — the engine field is `_path` (the bare namespace is the user's); pass `_path` (docs_read takes _id?, _path?, at?, include_ids?, repo?)"
3554 );
3555 assert_eq!(bare_path.body["data"]["fix"], r#"{"_path":"a.md"}"#);
3556 assert_eq!(
3557 s.call("docs_read", json!({ "_path": "//a.md" })).body["_id"],
3558 "d_0"
3559 );
3560 assert_eq!(read.body["properties"]["frontmatter"]["layer"], "canon");
3561 assert!(read.body["ids"].as_array().unwrap().len() >= 3);
3562 let q = s.call("query", json!({ "query": "select _title, layer, t: _nodes collect { _value where _kind == \"md:task\" } from docs where layer == \"canon\" && _nodes count { where _kind == \"md:task\" } == 1" }));
3563 assert!(!q.is_error, "{}", q.body);
3564 assert_eq!(q.body["hits"][0]["_title"], "Title");
3565 assert_eq!(q.body["hits"][0]["layer"], "canon");
3566 assert_eq!(q.body["hits"][0]["t"][0]["_value"], "task one");
3567 assert_eq!(q.body["hits"][0]["_id"], "d_0");
3568 assert_eq!(q.body["hits"][0]["_path"], "/a.md");
3569 assert_eq!(q.body["consumer"], "collect");
3570 let c = s.call(
3571 "query",
3572 json!({ "query": "blocks count { where text(\"hello\") }" }),
3573 );
3574 assert_eq!(c.body["count"], 1);
3575 let ok = s.call(
3578 "query",
3579 json!({ "query": "from docs where _path == \"a.md\"" }),
3580 );
3581 assert!(!ok.is_error, "{}", ok.body);
3582 assert_eq!(ok.body["hits"][0]["_id"], "d_0");
3583 let bad = s.call("query", json!({ "query": "select _path: layer from docs" }));
3584 assert!(bad.is_error);
3585 assert_eq!(bad.body["error"], "filter_invalid");
3586 assert_eq!(
3587 bad.body["message"],
3588 "projection `_path` collides with the hit envelope — alias it (`p: …`)"
3589 );
3590 let sem = s.call(
3591 "query",
3592 json!({ "query": "from docs where semantic(\"x\") > 0.5" }),
3593 );
3594 assert_eq!(sem.body["error"], "provider_unavailable");
3595 assert_eq!(sem.body["data"], json!({ "provider": null }));
3596 let outline = s.call("docs_outline", json!({ "_path": "a.md" }));
3597 let text = outline.body["text"].as_str().unwrap();
3598 assert!(text.starts_with("b_0 h1 Title §"), "{text}");
3599 assert!(text.contains("☐ task one"));
3600 let missing = s.call("docs_read", json!({ "_path": "nope.md" }));
3601 assert_eq!(missing.body["error"], "doc_missing");
3602 assert_eq!(
3603 missing.body["message"],
3604 "no document with _id or _path \"nope.md\""
3605 );
3606 assert_eq!(missing.body["data"], json!({ "_path": "nope.md" }));
3607 let repos = s.call("repos", json!({}));
3608 assert_eq!(repos.body["repos"][0]["slug"], "fixture");
3609 let unknown = s.call("docs_list", json!({ "repo": "zzz" }));
3610 assert_eq!(unknown.body["error"], "repo_not_found");
3611 }
3612
3613 #[test]
3614 fn insert_and_move_carry_the_destination_parent_cas_and_many_reads_keep_one_item() {
3615 let mut s = surface();
3616 s.call(
3617 "observe",
3618 json!({ "path": "a.md", "content": "# T\n\nOne.\n\nTwo.\n" }),
3619 );
3620 s.call("observe", json!({ "path": "b.md", "content": "# U\n" }));
3621 let stale = s.call(
3625 "blocks_insert",
3626 json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": "00" } }),
3627 );
3628 assert!(stale.is_error, "{}", stale.body);
3629 assert_eq!(stale.body["error"], "stale_expectation");
3630 assert_eq!(stale.body["retriable"], true);
3631 let current = stale.body["data"]["current"]["parent_children_hash"]
3632 .as_str()
3633 .expect("current hash")
3634 .to_owned();
3635 assert_eq!(current.len(), 64, "{current}");
3636 let ok = s.call(
3637 "blocks_insert",
3638 json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": current, "content_hash": "dropped, not checked" } }),
3639 );
3640 assert!(!ok.is_error, "{}", ok.body);
3641 assert_eq!(ok.body["committed"], true);
3642 let read = s.call("docs_read", json!({ "_path": "a.md" }));
3643 assert_eq!(read.body["_raw"], "# T\n\nOne.\n\nTwo.\n\nThree.\n");
3644 let stale = s.call(
3646 "blocks_move",
3647 json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": "00" } }),
3648 );
3649 assert_eq!(stale.body["error"], "stale_expectation", "{}", stale.body);
3650 let current = stale.body["data"]["current"]["parent_children_hash"]
3651 .as_str()
3652 .expect("current hash")
3653 .to_owned();
3654 let ok = s.call(
3655 "blocks_move",
3656 json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": current } }),
3657 );
3658 assert!(!ok.is_error, "{}", ok.body);
3659 let read = s.call("docs_read", json!({ "_path": "a.md" }));
3660 assert_eq!(read.body["_raw"], "# T\n\nThree.\n\nOne.\n\nTwo.\n\n");
3661 let dry = s.call(
3663 "plan",
3664 json!({ "effects": [{ "op": "move", "targets": [{ "kind": "block", "_id": "b_1" }], "into": { "kind": "document", "_path": "a.md" }, "at": "start", "expect": { "parent_children_hash": "00" } }] }),
3665 );
3666 assert_eq!(dry.body["error"], "stale_expectation", "{}", dry.body);
3667 let dry = s
3668 .preview_call(
3669 "blocks_move",
3670 &json!({ "blocks": ["b_1"], "to": "a.md", "at": "start", "expect": { "parent_children_hash": "00" } }),
3671 )
3672 .unwrap_err();
3673 assert_eq!(dry.code, "stale_expectation");
3674 let many = s.call(
3677 "docs_read_many",
3678 json!({ "docs": ["a.md", "b.md"], "budget_tokens": 1 }),
3679 );
3680 assert_eq!(
3681 many.body["items"].as_array().unwrap().len(),
3682 1,
3683 "{}",
3684 many.body
3685 );
3686 assert_eq!(many.body["items"][0]["_path"], "/a.md");
3687 assert_eq!(many.body["truncated"], true);
3688 let one = s.call(
3689 "docs_read_many",
3690 json!({ "docs": ["b.md"], "budget_tokens": 1 }),
3691 );
3692 assert_eq!(one.body["items"].as_array().unwrap().len(), 1);
3693 assert_eq!(one.body["truncated"], false);
3694 let nodes = s.call(
3695 "blocks_read_many",
3696 json!({ "_ids": ["b_zzz", "b_1", "b_2"], "resolution": "full", "budget_tokens": 1 }),
3697 );
3698 assert_eq!(
3699 nodes.body["nodes"].as_array().unwrap().len(),
3700 1,
3701 "{}",
3702 nodes.body
3703 );
3704 assert_eq!(nodes.body["nodes"][0]["_id"], "b_1");
3705 assert_eq!(nodes.body["truncated"], true);
3706 assert_eq!(nodes.body["unresolved"], json!(["b_zzz"]));
3707 }
3708
3709 #[test]
3710 fn writes_go_through_the_fixed_doc_store_and_fire_the_hook() {
3711 use std::cell::Cell;
3712 use std::rc::Rc;
3713 let fired = Rc::new(Cell::new(0));
3714 let f2 = Rc::clone(&fired);
3715 let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
3716 s.call(
3717 "observe",
3718 json!({ "path": "a.md", "content": "# T\n\nOne.\n" }),
3719 );
3720 assert_eq!(fired.get(), 1);
3721 let dry = s
3722 .preview_call(
3723 "blocks_insert",
3724 &json!({ "to": "a.md", "markdown": "Two." }),
3725 )
3726 .unwrap();
3727 assert!(dry.get("committed").is_none(), "a Plan: {dry}");
3728 assert_eq!(
3729 dry["new_ids"],
3730 json!([{ "symbol": "$new.0", "kind": "block" }])
3731 );
3732 assert_eq!(fired.get(), 1, "a preview never fires the hook");
3733 let plan = s.call("plan", json!({ "effects": [{ "op": "insert", "into": { "kind": "document", "_path": "a.md" }, "markdown": "Two." }] }));
3734 assert_eq!(
3735 plan.body["plan_id"], dry["plan_id"],
3736 "the preview IS the plan"
3737 );
3738 assert_eq!(fired.get(), 1, "`plan` never fires the hook");
3739 let wet = s.call("blocks_insert", json!({ "to": "a.md", "markdown": "Two." }));
3740 assert!(!wet.is_error, "{}", wet.body);
3741 assert_eq!(fired.get(), 2);
3742 assert_eq!(wet.body["plan_id"], dry["plan_id"]);
3744 assert_eq!(wet.body["committed"], true);
3745 assert_eq!(wet.body["commit"], "c_1");
3746 assert_eq!(wet.body["new_ids"], json!({ "$new.0": "b_2" }));
3747 assert_eq!(wet.body["revisions"][0]["_path"], "/a.md");
3748 assert!(wet.body.get("results").is_none() && wet.body.get("diffs").is_none());
3749 let read = s.call("docs_read", json!({ "_id": "d_0" }));
3750 assert_eq!(read.body["_raw"], "# T\n\nOne.\n\nTwo.\n");
3751 let upd = s.call(
3752 "blocks_update",
3753 json!({ "_id": "b_1", "markdown": "One, edited." }),
3754 );
3755 assert!(!upd.is_error, "{}", upd.body);
3756 assert!(
3757 upd.body.get("_id").is_none() && upd.body.get("ids").is_none(),
3758 "{}",
3759 upd.body
3760 );
3761 assert_eq!(upd.body["revisions"].as_array().map(Vec::len), Some(1));
3762 let attrs = s.call(
3763 "blocks_update",
3764 json!({ "_id": "b_1", "attrs": { "checked": true } }),
3765 );
3766 assert_eq!(
3767 attrs.body["message"],
3768 "`attrs` is no longer an argument — pass `checked` (blocks_update takes _id, checked?, expect?, markdown?, repo?)"
3769 );
3770 let dry = s.call(
3771 "blocks_update",
3772 json!({ "_id": "b_1", "markdown": "x", "dry_run": true }),
3773 );
3774 assert_eq!(
3775 dry.body["message"],
3776 "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (blocks_update takes _id, checked?, expect?, markdown?, repo?)"
3777 );
3778 let nothing = s.call("blocks_update", json!({ "_id": "b_1" }));
3779 assert_eq!(
3780 nothing.body["message"],
3781 "pass `markdown` and/or `checked` (blocks_update takes _id, checked?, expect?, markdown?, repo?)"
3782 );
3783 let bare = s.call("blocks_update", json!({ "id": "b_1", "markdown": "x" }));
3785 assert_eq!(
3786 bare.body["message"],
3787 "`id` is not an argument — the engine field is `_id` (the bare namespace is the user's); pass `_id` (blocks_update takes _id, checked?, expect?, markdown?, repo?)"
3788 );
3789 assert_eq!(bare.body["data"]["fix"], r#"{"_id":"b_1","markdown":"x"}"#);
3790 let app = s.call(
3791 "docs_append",
3792 json!({ "_path": "a.md", "section": "T", "text": "Three." }),
3793 );
3794 assert!(!app.is_error, "{}", app.body);
3795 let amb = s.call(
3796 "docs_append",
3797 json!({ "_path": "a.md", "section": "Nope", "text": "x" }),
3798 );
3799 assert_eq!(amb.body["error"], "parent_missing");
3800 assert_eq!(amb.body["message"], "no heading \"Nope\" in /a.md");
3801 let into = s.call("blocks_append", json!({ "_id": "b_0", "text": "Four." }));
3803 assert!(!into.is_error, "{}", into.body);
3804 let leaf = s.call("blocks_append", json!({ "_id": "b_1", "text": "x" }));
3805 assert_eq!(leaf.body["error"], "block_not_container", "{}", leaf.body);
3806 assert_eq!(
3809 leaf.body["data"],
3810 json!({ "_id": "b_1", "_type": "paragraph", "fix": "blocks_insert {\"at\":{\"after\":\"b_1\"},\"markdown\":\"x\",\"to\":\"/a.md\"}" })
3811 );
3812 let hist = s.call("blocks_history", json!({ "_id": "b_1" }));
3813 let entries = hist.body.as_array().unwrap();
3814 assert!(entries.len() >= 2, "{}", hist.body);
3815 assert_eq!(entries[0]["origin"], "api", "newest first");
3816 let du = s.call("docs_diff_unified", json!({ "_path": "a.md" }));
3817 assert!(!du.is_error, "{}", du.body);
3818 assert!(du.body["diff"].as_str().unwrap().contains("+Four."));
3819 }
3820
3821 #[test]
3828 fn doc_tools_return_receipts_and_preview_through_plan() {
3829 use std::cell::Cell;
3830 use std::rc::Rc;
3831 let fired = Rc::new(Cell::new(0));
3832 let f2 = Rc::clone(&fired);
3833 let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
3834 let b_src = "# B\n\nTarget.\n";
3835 let a_src = "# A\n\nSee [b](b.md).\n";
3836 let b = s.call("docs_create", json!({ "path": "b.md", "markdown": b_src }));
3837 assert!(!b.is_error, "{}", b.body);
3838 assert_eq!(
3839 b.body,
3840 json!({ "plan_id": b.body["plan_id"], "commit": "c_0", "revisions": [{ "_id": "d_0", "_path": "/b.md", "_hash": b.body["revisions"][0]["_hash"] }], "new_ids": { "$new.0": "d_0", "$new.1": "b_0", "$new.2": "b_1" }, "committed": true, "summary": ["create /b.md (2 blocks)"] }),
3841 "the created document is revisions[0], its symbols the document then its blocks"
3842 );
3843 let a = s.call("docs_create", json!({ "path": "a.md", "markdown": a_src }));
3844 assert_eq!(a.body["new_ids"]["$new.0"], "d_1", "{}", a.body);
3845 assert_eq!(fired.get(), 2);
3846 let count = |s: &Surface, table: &str| -> i64 {
3847 s.store
3848 .conn()
3849 .query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
3850 .unwrap()
3851 };
3852 let snapshot = |s: &Surface| {
3853 (
3854 count(s, "docs"),
3855 count(s, "commits"),
3856 count(s, "revisions"),
3857 count(s, "blocks"),
3858 )
3859 };
3860 let before = snapshot(&s);
3861 let keys = |v: &Json| -> Vec<String> { v.as_object().unwrap().keys().cloned().collect() };
3862
3863 let c = s
3865 .preview_call(
3866 "docs_create",
3867 &json!({ "path": "c.md", "markdown": "# C", "frontmatter": { "title": "C" } }),
3868 )
3869 .unwrap();
3870 assert_eq!(
3871 c["preview"]["diffs"],
3872 json!({ "/c.md": { "before": "", "after": "---\ntitle: C\n---\n\n# C\n" } })
3873 );
3874 assert_eq!(c["preview"]["revisions"][0]["_id"], "$new.0");
3875 assert_eq!(
3876 c["new_ids"],
3877 json!([{ "symbol": "$new.0", "kind": "document" }, { "symbol": "$new.1", "kind": "block" }])
3878 );
3879
3880 let m = s
3883 .preview_call(
3884 "docs_move",
3885 &json!({ "_path": "b.md", "to_path": "notes/b.md", "retarget_inbound": true }),
3886 )
3887 .unwrap();
3888 assert_eq!(
3889 keys(&m["preview"]["diffs"]),
3890 ["/a.md", "/b.md", "/notes/b.md"]
3891 );
3892 assert_eq!(
3893 m["preview"]["diffs"]["/a.md"],
3894 json!({ "before": a_src, "after": "# A\n\nSee [b](notes/b.md).\n" })
3895 );
3896 assert_eq!(
3897 m["preview"]["diffs"]["/b.md"],
3898 json!({ "before": b_src, "after": "" })
3899 );
3900 assert_eq!(
3901 m["preview"]["diffs"]["/notes/b.md"],
3902 json!({ "before": "", "after": b_src })
3903 );
3904 assert!(
3905 m.get("dangling").is_none(),
3906 "a preview is the Plan alone: {m}"
3907 );
3908 let plain = s
3910 .preview_call(
3911 "docs_move",
3912 &json!({ "_path": "b.md", "to_path": "notes/b.md", "retarget_inbound": false }),
3913 )
3914 .unwrap();
3915 assert_eq!(keys(&plain["preview"]["diffs"]), ["/b.md", "/notes/b.md"]);
3916
3917 let d = s
3919 .preview_call("docs_delete", &json!({ "_path": "a.md" }))
3920 .unwrap();
3921 assert_eq!(
3922 d["preview"]["diffs"],
3923 json!({ "/a.md": { "before": a_src, "after": "" } })
3924 );
3925
3926 let sm = s
3928 .preview_call(
3929 "docs_set_meta",
3930 &json!({ "_path": "a.md", "set": { "status": "open" } }),
3931 )
3932 .unwrap();
3933 assert_eq!(
3934 sm["preview"]["diffs"],
3935 json!({ "/a.md": { "before": a_src, "after": "---\nstatus: open\n---\n\n# A\n\nSee [b](b.md).\n" } })
3936 );
3937
3938 assert_eq!(fired.get(), 2, "previews never fire the mutation hook");
3941 assert_eq!(snapshot(&s), before);
3942 let taken = s.preview_call("docs_create", &json!({ "path": "a.md", "markdown": "x" }));
3943 assert_eq!(taken.unwrap_err().code, "path_taken");
3944 let missing = s.preview_call("docs_delete", &json!({ "_path": "nope.md" }));
3945 assert_eq!(missing.unwrap_err().code, "doc_missing");
3946 let dry = s.call("docs_delete", json!({ "_path": "a.md", "dry_run": true }));
3947 assert_eq!(
3948 dry.body["message"],
3949 "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (docs_delete takes _id?, _path?, repo?)"
3950 );
3951 let real = s.call(
3952 "docs_create",
3953 json!({ "path": "c.md", "markdown": "# C\n" }),
3954 );
3955 assert_eq!(
3956 real.body["new_ids"]["$new.0"], "d_2",
3957 "the preview burned no `d`"
3958 );
3959 assert_eq!(fired.get(), 3);
3960
3961 let moved = s.call(
3964 "docs_move",
3965 json!({ "_path": "b.md", "to_path": "notes/b.md" }),
3966 );
3967 assert!(!moved.is_error, "{}", moved.body);
3968 assert_eq!(
3969 keys(&moved.body),
3970 [
3971 "plan_id",
3972 "commit",
3973 "revisions",
3974 "new_ids",
3975 "committed",
3976 "summary",
3977 "retargeted",
3978 "dangling"
3979 ]
3980 );
3981 assert_eq!(
3982 moved.body["revisions"],
3983 json!([{ "_id": "d_1", "_path": "/a.md", "_hash": moved.body["revisions"][0]["_hash"] }])
3984 );
3985 assert_eq!(moved.body["dangling"], json!([]));
3986 assert_eq!(
3987 moved.body["retargeted"],
3988 json!({ "blocks": ["b_3"], "docs": ["d_1"] })
3989 );
3990 assert_eq!(
3991 s.call("docs_read", json!({ "_id": "d_0" })).body["_path"],
3992 "/notes/b.md"
3993 );
3994 let back = s.call(
3996 "docs_move",
3997 json!({ "_path": "notes/b.md", "to_path": "b.md", "retarget_inbound": false }),
3998 );
3999 assert_eq!(back.body["revisions"], json!([]), "{}", back.body);
4000 assert_eq!(
4001 back.body["dangling"],
4002 json!([{ "_doc_id": "d_1", "_path": "/a.md", "_block_id": "b_3", "target": "notes/b.md", "_anchor": null }])
4003 );
4004 assert_eq!(back.body["retargeted"], Json::Null);
4005 let sm = s.call(
4007 "docs_set_meta",
4008 json!({ "_path": "a.md", "set": { "status": "open" } }),
4009 );
4010 assert_eq!(
4011 sm.body["revisions"].as_array().map(Vec::len),
4012 Some(1),
4013 "{}",
4014 sm.body
4015 );
4016 assert_eq!(sm.body["committed"], true);
4017 let d = s.call("docs_delete", json!({ "_path": "a.md" }));
4018 assert_eq!(d.body["revisions"], json!([]), "{}", d.body);
4019 assert!(d.body["commit"].is_string());
4020 assert_eq!(
4021 s.call("docs_read", json!({ "_path": "a.md" })).body["error"],
4022 "doc_missing"
4023 );
4024 }
4025
4026 #[test]
4027 fn change_summaries_are_rooted_like_the_reference_renders_them() {
4028 assert_eq!(
4029 root_summary("observed: a.md, b/c.md — 3 inserted, 1 edited"),
4030 "observed: /a.md, /b/c.md — 3 inserted, 1 edited"
4031 );
4032 assert_eq!(
4033 root_summary("observed: deleted a.md"),
4034 "observed: deleted /a.md"
4035 );
4036 assert_eq!(
4037 root_summary("api(human:spec): moved a.md → b/a.md"),
4038 "api(human:spec): moved /a.md → /b/a.md"
4039 );
4040 assert_eq!(
4041 root_summary("api(human:spec): moved a.md → b/a.md; index.md, c.md — 31 edited"),
4042 "api(human:spec): moved /a.md → /b/a.md; /index.md, /c.md — 31 edited"
4043 );
4044 assert_eq!(root_summary("api(?): x.md"), "api(?): /x.md");
4045 assert_eq!(root_summary("observed: "), "observed: ");
4046 assert_eq!(root_summary("nonsense"), "nonsense");
4047 let page = root_changes_page(json!({
4048 "digests": [{ "summary": "observed: a.md", "revisions": [{ "_id": "d_0", "_path": "a.md", "_hash": "ff" }] }],
4049 "cursor": 1, "truncated": false, "head": 1
4050 }));
4051 assert_eq!(page["digests"][0]["revisions"][0]["_path"], "/a.md");
4052 assert_eq!(page["digests"][0]["summary"], "observed: /a.md");
4053 }
4054
4055 #[test]
4059 fn plan_returns_diffs_only_on_request() {
4060 let mut s = surface();
4061 let out = s.call(
4062 "observe",
4063 json!({ "path": "a.md", "content": "# A\n\nOne.\n" }),
4064 );
4065 assert!(!out.is_error, "{}", out.body);
4066 let effects = json!([{ "op": "insert", "into": "/a.md", "markdown": "Two." }]);
4067 let bare = s.call("plan", json!({ "effects": effects }));
4068 assert!(!bare.is_error, "{}", bare.body);
4069 assert!(bare.body["preview"].get("diffs").is_none(), "{}", bare.body);
4070 assert!(bare.body["preview"].get("summary").is_some());
4071 let with = s.call("plan", json!({ "effects": effects, "diffs": true }));
4072 assert_eq!(
4073 with.body["preview"]["diffs"]["/a.md"]["after"],
4074 "# A\n\nOne.\n\nTwo.\n"
4075 );
4076 assert_eq!(with.body["plan_id"], bare.body["plan_id"]);
4077 let mut p = with.body["preview"].as_object().unwrap().clone();
4078 p.remove("diffs");
4079 assert_eq!(Json::Object(p), bare.body["preview"]);
4080 let off = s.call("plan", json!({ "effects": effects, "diffs": false }));
4081 assert!(off.body["preview"].get("diffs").is_none());
4082 }
4083
4084 #[test]
4088 fn string_list_arguments_take_one_bare_string() {
4089 let mut s = surface();
4090 for (path, content) in [("a.md", "# A\n\nOne.\n"), ("b.md", "# B\n")] {
4091 let out = s.call("observe", json!({ "path": path, "content": content }));
4092 assert!(!out.is_error, "{}", out.body);
4093 }
4094 let one = s.call("docs_read_many", json!({ "docs": "a.md" }));
4095 assert!(!one.is_error, "{}", one.body);
4096 assert_eq!(one.body["items"][0]["_path"], "/a.md");
4097 let ids = s.call("blocks_read_many", json!({ "_ids": "b_1" }));
4098 assert_eq!(ids.body["nodes"][0]["_id"], "b_1");
4099 let roots = s.call("graph", json!({ "roots": "a.md", "degrees": 0 }));
4100 assert_eq!(roots.body["roots"], json!(["d_0"]));
4101 let joined = s.call(
4102 "docs_read_many",
4103 json!({ "docs": "a.md, b.md", "include_ids": true }),
4104 );
4105 assert!(joined.is_error);
4106 assert_eq!(joined.body["error"], "filter_invalid");
4107 assert_eq!(
4108 joined.body["data"]["fix"],
4109 r#"{"docs":["a.md","b.md"],"include_ids":true}"#
4110 );
4111 }
4112
4113 #[test]
4116 fn plain_shape_errors_carry_their_fix() {
4117 let mut s = surface();
4118 let out = s.call(
4119 "observe",
4120 json!({ "path": "a.md", "content": "# A\n\n- one\n - nested\n" }),
4121 );
4122 assert!(!out.is_error, "{}", out.body);
4123 for tool in ["plan", "apply"] {
4124 let e = s.call(tool, json!({ "effects": [{ "op": "insert", "into": { "kind": "block", "_id": "b_3" }, "at": "after", "markdown": "x" }] }));
4125 assert!(e.is_error, "{tool}: {}", e.body);
4126 assert_eq!(e.body["error"], "filter_invalid");
4127 assert_eq!(
4128 e.body["data"]["fix"],
4129 r#"{"effects":[{"at":{"after":"b_3"},"into":{"_id":"b_2","kind":"block"},"markdown":"x","op":"insert"}]}"#,
4130 "{tool}"
4131 );
4132 }
4133 let e = s.call("plan", json!({ "effects": [{ "op": "set", "target": { "kind": "block", "_id": "b_1" }, "field": "text", "value": "x" }] }));
4134 assert_eq!(
4135 e.body["data"]["fix"],
4136 r#"{"effects":[{"field":"markdown","op":"set","target":{"_id":"b_1","kind":"block"},"value":"x"}]}"#
4137 );
4138 }
4139}