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