1use std::path::Path;
16
17use omgbase_format::BlockKind;
18use omgbase_format::text::{normalize_text, normalize_visible_text};
19use omgbase_reconcile::Config;
20use omgbase_search::EmbeddingProvider;
21use omgbase_store::docs_ops::{canonical, retargeted_json};
22use omgbase_store::links::inbound_links_to;
23use omgbase_store::mutate_kernel::effects::{Admission, EffectExpect, Target as EffectTarget};
24use omgbase_store::mutate_kernel::{At, ErrorCode, Parent};
25use omgbase_store::{
26 ApplyOrigin, DocStore, Expect, FsDocStore, MutationError, Plan, PlanContext, PublicEffect,
27 QueryVector, Receipt, Retargeted, Store, sugar,
28};
29use omgbase_sync::{RealFileSystem, RepoRow};
30use rusqlite::{OptionalExtension, params};
31use serde_json::{Map, Value as Json, json};
32
33use crate::arguments::{argument_error, validate_arguments};
34use crate::error::{Result, SurfaceError};
35use crate::graph::{GraphArgs, graph_neighborhood};
36use crate::history;
37use crate::links;
38use crate::paths::{reference_keyed, reference_path, root_field, storage_path};
39use crate::query::{QueryOptions, query};
40use crate::read::{self, Resolution, ResolvedRef};
41use crate::reference::QUERY_SYNTAX;
42use crate::version::{BuildInfo, version_info};
43
44pub const ACTOR: &str = "agent:mcp";
46
47#[derive(Clone, Debug, PartialEq)]
49pub struct ToolSpec {
50 pub name: &'static str,
51 pub description: &'static str,
52 pub input_schema: Json,
54}
55
56#[derive(Clone, Debug, PartialEq)]
59pub struct ToolOutcome {
60 pub body: Json,
61 pub is_error: bool,
62}
63
64enum WriteTarget {
66 Derived,
69 Fixed(Box<dyn DocStore>),
72}
73
74pub struct Surface {
76 store: Store,
77 default_repo: String,
78 provider: Option<Box<dyn EmbeddingProvider>>,
79 provider_name: Option<String>,
82 on_mutation: Option<Box<dyn FnMut()>>,
83 clock: Box<dyn FnMut() -> String>,
84 writes: WriteTarget,
85 config: Config,
86 actor: String,
88 build_info: BuildInfo,
90 preview: bool,
94 last_plan: Option<Plan>,
98}
99
100fn bad_args(msg: impl Into<String>) -> SurfaceError {
103 SurfaceError::filter_invalid(msg, "arguments")
104}
105
106fn arg_str<'a>(args: &'a Json, key: &str) -> Option<&'a str> {
107 args.get(key).and_then(Json::as_str)
108}
109
110fn arg_string(args: &Json, key: &str) -> Result<String> {
111 arg_str(args, key)
112 .map(str::to_owned)
113 .ok_or_else(|| bad_args(format!("`{key}` must be a string")))
114}
115
116fn arg_i64(args: &Json, key: &str) -> Result<Option<i64>> {
117 match args.get(key) {
118 None | Some(Json::Null) => Ok(None),
119 Some(v) => v
120 .as_f64()
121 .filter(|n| n.fract() == 0.0)
122 .map(|n| Some(n as i64))
123 .ok_or_else(|| bad_args(format!("`{key}` must be an integer"))),
124 }
125}
126
127fn arg_usize(args: &Json, key: &str) -> Result<Option<usize>> {
128 Ok(arg_i64(args, key)?.map(|n| usize::try_from(n).unwrap_or(0)))
129}
130
131fn arg_bool(args: &Json, key: &str) -> Result<Option<bool>> {
132 match args.get(key) {
133 None | Some(Json::Null) => Ok(None),
134 Some(Json::Bool(b)) => Ok(Some(*b)),
135 Some(_) => Err(bad_args(format!("`{key}` must be a boolean"))),
136 }
137}
138
139fn arg_strings(args: &Json, key: &str) -> Result<Vec<String>> {
140 let Some(arr) = args.get(key).and_then(Json::as_array) else {
141 return Err(bad_args(format!("`{key}` must be an array of strings")));
142 };
143 arr.iter()
144 .map(|v| {
145 v.as_str()
146 .map(str::to_owned)
147 .ok_or_else(|| bad_args(format!("`{key}` must be an array of strings")))
148 })
149 .collect()
150}
151
152fn arg_object<'a>(args: &'a Json, key: &str) -> Result<Option<&'a Map<String, Json>>> {
153 match args.get(key) {
154 None | Some(Json::Null) => Ok(None),
155 Some(Json::Object(o)) => Ok(Some(o)),
156 Some(_) => Err(bad_args(format!("`{key}` must be an object"))),
157 }
158}
159
160fn hex_field(e: &Map<String, Json>, k: &str) -> Option<String> {
161 e.get(k).and_then(Json::as_str).map(str::to_owned)
162}
163
164fn arg_expect(args: &Json) -> Result<Option<Expect>> {
167 Ok(arg_object(args, "expect")?.map(|e| Expect {
168 content_hash: hex_field(e, "content_hash"),
169 parent_children_hash: hex_field(e, "parent_children_hash"),
170 }))
171}
172
173fn arg_parent_expect(args: &Json) -> Result<Option<Expect>> {
178 Ok(arg_object(args, "expect")?.map(|e| Expect {
179 content_hash: None,
180 parent_children_hash: hex_field(e, "parent_children_hash"),
181 }))
182}
183
184fn resolution_arg(args: &Json, default: Resolution) -> Result<Resolution> {
185 match arg_str(args, "resolution") {
186 None => Ok(default),
187 Some(s) => Resolution::parse(s).ok_or_else(|| {
188 bad_args("`resolution` must be one of skeleton, outline, text, raw, full")
189 }),
190 }
191}
192
193fn merge(mut a: Map<String, Json>, b: Json) -> Json {
195 if let Json::Object(o) = b {
196 for (k, v) in o {
197 a.insert(k, v);
198 }
199 }
200 Json::Object(a)
201}
202
203fn require_some(tool: &str, arg: &str, list: Vec<String>, spec: &ToolSpec) -> Result<Vec<String>> {
220 if list.is_empty() {
221 return Err(argument_error(
222 tool,
223 &format!("`{arg}` must name at least one block"),
224 &spec.input_schema,
225 ));
226 }
227 Ok(list)
228}
229
230fn effect_expect(e: Option<Expect>) -> Option<EffectExpect> {
233 e.map(|e| EffectExpect {
234 content_hash: e.content_hash,
235 parent_children_hash: e.parent_children_hash,
236 revision: None,
237 })
238}
239
240fn placement_target(parent: Parent, doc_id: String) -> EffectTarget {
242 match parent {
243 Parent::Doc => EffectTarget::Document {
244 id: Some(doc_id),
245 path: None,
246 },
247 Parent::Block(id) => EffectTarget::Block { id },
248 Parent::Section { heading } => EffectTarget::Section {
249 heading_id: heading,
250 },
251 }
252}
253
254type SugarOutcome = (Plan, Option<Receipt>);
257
258fn strip_effect_index(e: omgbase_store::Error) -> omgbase_store::Error {
262 match e {
263 omgbase_store::Error::Mutation(mut m) => {
264 m.data.remove("effect_index");
265 omgbase_store::Error::Mutation(m)
266 }
267 other => other,
268 }
269}
270
271fn sugar_json((plan, receipt): &SugarOutcome) -> Json {
273 match receipt {
274 Some(r) => receipt_json(r),
275 None => plan_json(plan),
276 }
277}
278
279fn schema(props: &[(&str, Json)], required: &[&str], repo: bool) -> Json {
280 let mut p = Map::new();
281 for (k, v) in props {
282 p.insert((*k).to_owned(), v.clone());
283 }
284 if repo {
285 p.insert("repo".to_owned(), json!({ "type": "string" }));
286 }
287 json!({ "type": "object", "properties": p, "required": required })
288}
289
290fn s() -> Json {
291 json!({ "type": "string" })
292}
293fn i() -> Json {
294 json!({ "type": "integer" })
295}
296fn b() -> Json {
297 json!({ "type": "boolean" })
298}
299fn strings() -> Json {
300 json!({ "type": "array", "items": { "type": "string" } })
301}
302fn obj() -> Json {
303 json!({ "type": "object" })
304}
305fn nullable_string() -> Json {
306 json!({ "type": ["string", "null"] })
307}
308fn resolution_schema() -> Json {
309 json!({ "type": "string", "enum": ["skeleton", "outline", "text", "raw", "full"] })
310}
311fn at_schema() -> Json {
312 json!({ "oneOf": [
313 { "const": "start" }, { "const": "end" },
314 { "type": "object", "properties": { "before": { "type": "string" } }, "required": ["before"] },
315 { "type": "object", "properties": { "after": { "type": "string" } }, "required": ["after"] }
316 ] })
317}
318fn expect_schema() -> Json {
319 json!({ "type": "object", "properties": { "content_hash": { "type": "string" }, "parent_children_hash": { "type": "string" } } })
320}
321fn effects_schema() -> Json {
326 json!({ "type": "array", "items": { "type": "object" } })
327}
328
329fn parent_expect_schema() -> Json {
330 json!({ "type": "object", "properties": { "parent_children_hash": { "type": "string" } } })
331}
332
333#[must_use]
338pub fn tools() -> Vec<ToolSpec> {
339 catalog().to_vec()
340}
341
342pub(crate) fn catalog() -> &'static [ToolSpec] {
344 static CATALOG: std::sync::OnceLock<Vec<ToolSpec>> = std::sync::OnceLock::new();
345 CATALOG.get_or_init(build_catalog)
346}
347
348pub(crate) fn tool_spec(name: &str) -> Option<&'static ToolSpec> {
350 catalog().iter().find(|t| t.name == name)
351}
352
353#[allow(clippy::too_many_lines)]
354fn build_catalog() -> Vec<ToolSpec> {
355 use crate::descriptions as d;
356 let doc = |extra: &[(&str, Json)], required: &[&str]| {
359 let mut props = vec![("id", s()), ("path", s())];
360 props.extend(extra.iter().cloned());
361 schema(&props, required, true)
362 };
363 vec![
364 ToolSpec {
365 name: "docs_outline",
366 description: d::DOCS_OUTLINE,
367 input_schema: doc(
368 &[
369 (
370 "resolution",
371 json!({ "type": "string", "enum": ["skeleton", "outline"] }),
372 ),
373 ("depth", i()),
374 ("budget_tokens", i()),
375 ],
376 &[],
377 ),
378 },
379 ToolSpec {
380 name: "docs_read",
381 description: d::DOCS_READ,
382 input_schema: doc(&[("include_ids", b())], &[]),
383 },
384 ToolSpec {
385 name: "docs_read_many",
386 description: d::DOCS_READ_MANY,
387 input_schema: schema(
388 &[
389 ("docs", strings()),
390 ("include_ids", b()),
391 ("budget_tokens", i()),
392 ],
393 &["docs"],
394 true,
395 ),
396 },
397 ToolSpec {
398 name: "blocks_read",
399 description: d::BLOCKS_READ,
400 input_schema: schema(
401 &[("id", s()), ("resolution", resolution_schema())],
402 &["id"],
403 true,
404 ),
405 },
406 ToolSpec {
407 name: "nodes_block_read",
408 description: d::NODES_BLOCK_READ,
409 input_schema: schema(
410 &[("id", s()), ("resolution", resolution_schema())],
411 &["id"],
412 true,
413 ),
414 },
415 ToolSpec {
416 name: "blocks_read_many",
417 description: d::BLOCKS_READ_MANY,
418 input_schema: schema(
419 &[
420 ("ids", strings()),
421 ("resolution", resolution_schema()),
422 ("budget_tokens", i()),
423 ],
424 &["ids"],
425 true,
426 ),
427 },
428 ToolSpec {
429 name: "docs_tree",
430 description: d::DOCS_TREE,
431 input_schema: schema(
432 &[
433 ("path", s()),
434 ("depth", i()),
435 ("limit", i()),
436 ("cursor", nullable_string()),
437 ("budget_tokens", i()),
438 ],
439 &[],
440 true,
441 ),
442 },
443 ToolSpec {
444 name: "docs_list",
445 description: d::DOCS_LIST,
446 input_schema: schema(
447 &[
448 ("path_glob", s()),
449 ("limit", i()),
450 ("cursor", nullable_string()),
451 ("budget_tokens", i()),
452 ],
453 &[],
454 true,
455 ),
456 },
457 ToolSpec {
458 name: "query_syntax",
459 description: d::QUERY_SYNTAX,
460 input_schema: schema(&[], &[], false),
461 },
462 ToolSpec {
463 name: "query",
464 description: d::QUERY,
465 input_schema: schema(
466 &[
467 ("query", s()),
468 ("limit", i()),
469 ("cursor", nullable_string()),
470 ],
471 &["query"],
472 true,
473 ),
474 },
475 ToolSpec {
476 name: "graph",
477 description: d::GRAPH,
478 input_schema: schema(
479 &[
480 ("roots", strings()),
481 ("degrees", i()),
482 (
483 "direction",
484 json!({ "type": "string", "enum": ["in", "out", "both"] }),
485 ),
486 ("predicate", s()),
487 ("select", strings()),
488 ("max_documents", i()),
489 ],
490 &["roots"],
491 true,
492 ),
493 },
494 ToolSpec {
495 name: "blocks_search",
496 description: d::BLOCKS_SEARCH,
497 input_schema: schema(&[("q", s()), ("limit", i())], &["q"], true),
498 },
499 ToolSpec {
500 name: "blocks_resolve",
501 description: d::BLOCKS_RESOLVE,
502 input_schema: schema(&[("query", s()), ("limit", i())], &["query"], true),
503 },
504 ToolSpec {
505 name: "apply",
506 description: d::APPLY,
507 input_schema: schema(
508 &[("effects", effects_schema()), ("reason", s())],
509 &["effects"],
510 true,
511 ),
512 },
513 ToolSpec {
514 name: "plan",
515 description: d::PLAN,
516 input_schema: schema(&[("effects", effects_schema())], &["effects"], true),
517 },
518 ToolSpec {
519 name: "execute",
520 description: d::EXECUTE,
521 input_schema: schema(
522 &[
523 ("plan", obj()),
524 ("plan_id", s()),
525 ("effects", effects_schema()),
526 ("reason", s()),
527 ],
528 &[],
529 true,
530 ),
531 },
532 ToolSpec {
533 name: "blocks_insert",
534 description: d::BLOCKS_INSERT,
535 input_schema: schema(
536 &[
537 ("to", s()),
538 ("markdown", s()),
539 ("at", at_schema()),
540 ("expect", parent_expect_schema()),
541 ],
542 &["to", "markdown"],
543 true,
544 ),
545 },
546 ToolSpec {
547 name: "blocks_update",
548 description: d::BLOCKS_UPDATE,
549 input_schema: schema(
550 &[
551 ("id", s()),
552 ("markdown", s()),
553 ("checked", b()),
554 ("expect", expect_schema()),
555 ],
556 &["id"],
557 true,
558 ),
559 },
560 ToolSpec {
561 name: "blocks_move",
562 description: d::BLOCKS_MOVE,
563 input_schema: schema(
564 &[
565 ("blocks", strings()),
566 ("to", s()),
567 ("at", at_schema()),
568 ("expect", parent_expect_schema()),
569 ],
570 &["blocks", "to"],
571 true,
572 ),
573 },
574 ToolSpec {
575 name: "blocks_remove",
576 description: d::BLOCKS_REMOVE,
577 input_schema: schema(&[("blocks", strings())], &["blocks"], true),
578 },
579 ToolSpec {
580 name: "blocks_split",
581 description: d::BLOCKS_SPLIT,
582 input_schema: schema(
583 &[
584 ("id", s()),
585 (
586 "at",
587 json!({ "type": "array", "items": { "type": "integer" } }),
588 ),
589 ],
590 &["id", "at"],
591 true,
592 ),
593 },
594 ToolSpec {
595 name: "blocks_merge",
596 description: d::BLOCKS_MERGE,
597 input_schema: schema(
598 &[("blocks", strings()), ("separator", s())],
599 &["blocks"],
600 true,
601 ),
602 },
603 ToolSpec {
604 name: "blocks_check",
605 description: d::BLOCKS_CHECK,
606 input_schema: schema(&[("ids", strings()), ("checked", b())], &["ids"], true),
607 },
608 ToolSpec {
609 name: "nodes_set",
610 description: d::NODES_SET,
611 input_schema: schema(
612 &[("id", s()), ("prop", s()), ("value", s())],
613 &["id", "prop", "value"],
614 true,
615 ),
616 },
617 ToolSpec {
618 name: "blocks_append",
619 description: d::BLOCKS_APPEND,
620 input_schema: schema(&[("id", s()), ("text", s())], &["id", "text"], true),
621 },
622 ToolSpec {
623 name: "docs_append",
624 description: d::DOCS_APPEND,
625 input_schema: doc(&[("text", s()), ("section", s())], &["text"]),
626 },
627 ToolSpec {
628 name: "links_retarget",
629 description: d::LINKS_RETARGET,
630 input_schema: schema(
631 &[("from_target", s()), ("to_target", s()), ("path_glob", s())],
632 &["from_target", "to_target"],
633 true,
634 ),
635 },
636 ToolSpec {
637 name: "links_stale",
638 description: d::LINKS_STALE,
639 input_schema: schema(
640 &[("path_glob", s()), ("limit", i()), ("summary", b())],
641 &[],
642 true,
643 ),
644 },
645 ToolSpec {
646 name: "links_repair",
647 description: d::LINKS_REPAIR,
648 input_schema: schema(
649 &[
650 (
651 "repairs",
652 json!({ "type": "array", "items": { "type": "object", "properties": { "from": { "type": "string" }, "to": { "type": "string" } }, "required": ["from", "to"] } }),
653 ),
654 ("from_target", s()),
655 ("to_target", s()),
656 ("path_glob", s()),
657 ],
658 &[],
659 true,
660 ),
661 },
662 ToolSpec {
663 name: "docs_create",
664 description: d::DOCS_CREATE,
665 input_schema: schema(
666 &[("path", s()), ("markdown", s()), ("frontmatter", obj())],
667 &["path", "markdown"],
668 true,
669 ),
670 },
671 ToolSpec {
672 name: "docs_move",
673 description: d::DOCS_MOVE,
674 input_schema: doc(&[("to_path", s()), ("retarget_inbound", b())], &["to_path"]),
675 },
676 ToolSpec {
677 name: "docs_delete",
678 description: d::DOCS_DELETE,
679 input_schema: doc(&[], &[]),
680 },
681 ToolSpec {
682 name: "docs_set_meta",
683 description: d::DOCS_SET_META,
684 input_schema: doc(&[("set", obj()), ("unset", strings())], &[]),
685 },
686 ToolSpec {
687 name: "docs_update",
688 description: d::DOCS_UPDATE,
689 input_schema: doc(&[("content", s()), ("reason", s())], &["content"]),
690 },
691 ToolSpec {
692 name: "observe",
693 description: d::OBSERVE,
694 input_schema: schema(
695 &[("path", s()), ("content", s())],
696 &["path", "content"],
697 true,
698 ),
699 },
700 ToolSpec {
701 name: "observe_many",
702 description: d::OBSERVE_MANY,
703 input_schema: schema(
704 &[(
705 "files",
706 json!({ "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "content": { "type": "string" } }, "required": ["path", "content"] } }),
707 )],
708 &["files"],
709 true,
710 ),
711 },
712 ToolSpec {
713 name: "observe_delete",
714 description: d::OBSERVE_DELETE,
715 input_schema: schema(&[("path", s())], &["path"], true),
716 },
717 ToolSpec {
718 name: "blocks_history",
719 description: d::BLOCKS_HISTORY,
720 input_schema: schema(&[("id", s()), ("limit", i())], &["id"], false),
721 },
722 ToolSpec {
723 name: "docs_diff",
724 description: d::DOCS_DIFF,
725 input_schema: doc(
726 &[("from_rev", s()), ("to_rev", s())],
727 &["from_rev", "to_rev"],
728 ),
729 },
730 ToolSpec {
731 name: "docs_diff_unified",
732 description: d::DOCS_DIFF_UNIFIED,
733 input_schema: doc(&[("from_rev", s()), ("to_rev", s())], &[]),
734 },
735 ToolSpec {
736 name: "docs_read_at",
737 description: d::DOCS_READ_AT,
738 input_schema: doc(&[("rev", s())], &["rev"]),
739 },
740 ToolSpec {
741 name: "docs_history",
742 description: d::DOCS_HISTORY,
743 input_schema: schema(
744 &[
745 ("path_glob", s()),
746 ("id", s()),
747 ("path", s()),
748 ("include_deleted", b()),
749 ("limit", i()),
750 ],
751 &[],
752 true,
753 ),
754 },
755 ToolSpec {
756 name: "changes_since",
757 description: d::CHANGES_SINCE,
758 input_schema: schema(
759 &[
760 ("cursor", i()),
761 (
762 "origin",
763 json!({ "type": "string", "enum": ["api", "observed", "import"] }),
764 ),
765 ("limit", i()),
766 ],
767 &[],
768 true,
769 ),
770 },
771 ToolSpec {
772 name: "repos_status",
773 description: d::REPOS_STATUS,
774 input_schema: schema(&[], &[], true),
775 },
776 ToolSpec {
777 name: "sync_status",
778 description: d::SYNC_STATUS,
779 input_schema: schema(&[], &[], true),
780 },
781 ToolSpec {
782 name: "repos",
783 description: d::REPOS,
784 input_schema: schema(&[], &[], false),
785 },
786 ToolSpec {
787 name: "version",
788 description: d::VERSION,
789 input_schema: schema(&[], &[], false),
790 },
791 ]
792}
793
794impl Surface {
795 #[must_use]
798 pub fn new(
799 store: Store,
800 default_repo: &str,
801 provider: Option<Box<dyn EmbeddingProvider>>,
802 ) -> Self {
803 let _ = store
814 .conn()
815 .execute_batch("PRAGMA cache_size = -16000; PRAGMA temp_store = MEMORY;");
816 Self {
817 store,
818 default_repo: default_repo.to_owned(),
819 provider,
820 provider_name: None,
821 on_mutation: None,
822 clock: Box::new(omgbase_sync::now_ts),
823 writes: WriteTarget::Derived,
824 config: Config::default(),
825 actor: ACTOR.to_owned(),
826 build_info: BuildInfo::default(),
827 preview: false,
828 last_plan: None,
829 }
830 }
831
832 #[must_use]
835 pub fn with_provider_name(mut self, name: &str) -> Self {
836 self.provider_name = Some(name.to_owned());
837 self
838 }
839
840 #[must_use]
843 pub fn with_actor(mut self, actor: &str) -> Self {
844 self.set_actor(actor);
845 self
846 }
847
848 pub fn set_actor(&mut self, actor: &str) {
850 self.actor = actor.to_owned();
851 }
852
853 #[must_use]
856 pub fn with_doc_store(mut self, doc_store: Box<dyn DocStore>) -> Self {
857 self.writes = WriteTarget::Fixed(doc_store);
858 self
859 }
860
861 #[must_use]
863 pub fn with_clock(mut self, clock: impl FnMut() -> String + 'static) -> Self {
864 self.clock = Box::new(clock);
865 self
866 }
867
868 #[must_use]
870 pub fn with_mutation_hook(mut self, hook: impl FnMut() + 'static) -> Self {
871 self.on_mutation = Some(Box::new(hook));
872 self
873 }
874
875 #[must_use]
877 pub fn with_config(mut self, config: Config) -> Self {
878 self.config = config;
879 self
880 }
881
882 #[must_use]
886 pub fn with_build_info(mut self, build_info: BuildInfo) -> Self {
887 self.build_info = build_info;
888 self
889 }
890
891 #[must_use]
892 pub fn store(&self) -> &Store {
893 &self.store
894 }
895
896 pub fn store_mut(&mut self) -> &mut Store {
897 &mut self.store
898 }
899
900 #[must_use]
901 pub fn default_repo(&self) -> &str {
902 &self.default_repo
903 }
904
905 #[must_use]
907 pub fn tools(&self) -> Vec<ToolSpec> {
908 tools()
909 }
910
911 #[must_use]
915 pub fn is_write_tool(name: &str) -> bool {
916 matches!(
917 name,
918 "apply"
919 | "plan"
920 | "execute"
921 | "blocks_insert"
922 | "blocks_update"
923 | "blocks_move"
924 | "blocks_remove"
925 | "blocks_split"
926 | "blocks_merge"
927 | "blocks_check"
928 | "nodes_set"
929 | "blocks_append"
930 | "docs_append"
931 | "links_retarget"
932 | "links_repair"
933 | "docs_create"
934 | "docs_move"
935 | "docs_delete"
936 | "docs_set_meta"
937 | "docs_update"
938 | "observe"
939 | "observe_many"
940 | "observe_delete"
941 )
942 }
943
944 pub fn call(&mut self, name: &str, args: Json) -> ToolOutcome {
946 match self.call_result(name, &args) {
947 Ok(body) => ToolOutcome {
948 body,
949 is_error: false,
950 },
951 Err(e) => ToolOutcome {
952 body: e.to_json(),
953 is_error: true,
954 },
955 }
956 }
957
958 fn now(&mut self) -> String {
959 (self.clock)()
960 }
961
962 fn notify(&mut self) {
963 if let Some(hook) = &mut self.on_mutation {
964 hook();
965 }
966 }
967
968 fn repo_rows(&self) -> Result<Vec<RepoRow>> {
971 Ok(omgbase_sync::workspace::list_repos(&self.store)?)
972 }
973
974 fn scope(&self, args: &Json) -> Result<(String, Option<String>)> {
976 let rows = self.repo_rows()?;
977 match arg_str(args, "repo") {
978 None => {
979 let root = rows
980 .iter()
981 .find(|r| r.repo_id == self.default_repo)
982 .and_then(|r| r.root_path.clone());
983 Ok((self.default_repo.clone(), root))
984 }
985 Some(slug) => rows
986 .iter()
987 .find(|r| r.slug == slug)
988 .map(|r| (r.repo_id.clone(), r.root_path.clone()))
989 .ok_or_else(|| {
990 SurfaceError::with_data(
991 "repo_not_found",
992 format!("no repo '{slug}' in this workspace"),
993 json!({ "repo": slug }),
994 )
995 }),
996 }
997 }
998
999 fn require_root(&self, root: Option<&str>) -> Result<()> {
1000 if matches!(self.writes, WriteTarget::Fixed(_)) || root.is_some() {
1001 Ok(())
1002 } else {
1003 Err(SurfaceError::new(
1004 "repo_not_found",
1005 "repo has no filesystem source; mutation disabled",
1006 ))
1007 }
1008 }
1009
1010 fn with_writes<T>(
1012 &mut self,
1013 root: Option<&str>,
1014 f: impl FnOnce(&mut Store, &mut dyn DocStore) -> Result<T>,
1015 ) -> Result<T> {
1016 self.require_root(root)?;
1017 match &mut self.writes {
1018 WriteTarget::Fixed(ds) => f(&mut self.store, ds.as_mut()),
1019 WriteTarget::Derived => {
1020 let mut fs = FsDocStore::new(root.expect("checked by require_root"));
1021 f(&mut self.store, &mut fs)
1022 }
1023 }
1024 }
1025
1026 pub fn locate_document(&self, repo_id: &str, args: &Json) -> Result<String> {
1043 self.locate_doc(repo_id, "docs_update", args)
1044 }
1045
1046 fn locate_doc(&self, repo_id: &str, tool: &str, args: &Json) -> Result<String> {
1047 let (id_key, path_key) = ("id", "path");
1048 let schema = &tool_spec(tool).expect("a catalog tool").input_schema;
1049 let id = arg_str(args, id_key);
1050 let path = arg_str(args, path_key);
1051 if id.is_none() && path.is_none() {
1052 return Err(argument_error(
1053 tool,
1054 &format!("pass {id_key} or {path_key}"),
1055 schema,
1056 ));
1057 }
1058 let mut given = Map::new();
1059 if let Some(v) = id {
1060 given.insert(id_key.to_owned(), json!(v));
1061 }
1062 if let Some(v) = path {
1063 given.insert(path_key.to_owned(), json!(v));
1064 }
1065 let missing = |value: &str| {
1066 SurfaceError::with_data(
1067 "doc_missing",
1068 format!(
1069 "no document with {id_key} or {path_key} {}",
1070 Json::String(value.to_owned())
1071 ),
1072 Json::Object(given.clone()),
1073 )
1074 };
1075 let conn = self.store.conn();
1076 let by_id = match id {
1077 Some(v) => {
1078 let info = read::find_doc_by_id(conn, v)?.filter(|i| i.repo_id == repo_id);
1079 Some(info.ok_or_else(|| missing(v))?.doc_id)
1080 }
1081 None => None,
1082 };
1083 let by_path = match path {
1084 Some(v) => Some(
1085 read::find_doc_by_path(conn, repo_id, v)?
1086 .ok_or_else(|| missing(v))?
1087 .doc_id,
1088 ),
1089 None => None,
1090 };
1091 if let (Some(a), Some(b)) = (&by_id, &by_path)
1092 && a != b
1093 {
1094 return Err(argument_error(
1095 tool,
1096 &format!(
1097 "{id_key} {} and {path_key} {} name different documents",
1098 Json::String(id.unwrap_or_default().to_owned()),
1099 Json::String(path.unwrap_or_default().to_owned())
1100 ),
1101 schema,
1102 ));
1103 }
1104 Ok(by_id.or(by_path).expect("one locator given"))
1105 }
1106
1107 fn require_rankable(&self, repo_id: &str, semantic: bool) -> Result<()> {
1114 let Some(provider) = self.provider.as_deref().filter(|_| semantic) else {
1115 return Ok(());
1116 };
1117 let model = provider.model();
1118 let any: Option<i64> = self
1119 .store
1120 .conn()
1121 .query_row(
1122 "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)",
1123 params![repo_id, model],
1124 |r| r.get(0),
1125 )
1126 .optional()?;
1127 if any.is_some() {
1128 return Ok(());
1129 }
1130 let who = match &self.provider_name {
1131 Some(name) => format!("provider {} (model {model})", Json::String(name.clone())),
1132 None => format!("model {model}"),
1133 };
1134 Err(SurfaceError::with_data(
1135 "provider_unavailable",
1136 format!(
1137 "semantic(...) cannot rank: {who} is configured but nothing in this repository is embedded under it — run omg embed drain"
1138 ),
1139 json!({ "provider": self.provider_name, "model": model }),
1140 ))
1141 }
1142
1143 fn doc_of_block(&self, block_id: &str) -> Result<String> {
1145 self.doc_id_of_block(block_id)?
1146 .ok_or_else(|| block_missing(block_id))
1147 }
1148
1149 fn resolve_section_heading(
1157 &self,
1158 doc_id: &str,
1159 doc_path: &str,
1160 section: &str,
1161 ) -> Result<String> {
1162 let conn = self.store.conn();
1163 let needle = if section.trim_start_matches([' ', '\t']).starts_with('#') {
1164 normalize_visible_text(section, BlockKind::Heading, 0)
1165 } else {
1166 normalize_text(section)
1167 };
1168 let mut stmt = conn.prepare(
1169 "SELECT block_id, doc_id, text FROM blocks WHERE doc_id = ?1 AND type = 'heading' AND deleted_commit IS NULL",
1170 )?;
1171 let rows: Vec<(String, String, String)> = stmt
1172 .query_map(params![doc_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
1173 .collect::<std::result::Result<_, _>>()?;
1174 let matches: Vec<&(String, String, String)> = rows
1175 .iter()
1176 .filter(|(_, _, text)| normalize_text(text) == needle)
1177 .collect();
1178 let shown = reference_path(doc_path);
1179 match matches.len() {
1180 0 => Err(SurfaceError::with_data(
1181 "parent_missing",
1182 format!("no heading {} in {shown}", Json::String(section.to_owned())),
1183 json!({ "section": section, "path": shown }),
1184 )),
1185 1 => Ok(matches[0].0.clone()),
1186 n => Err(SurfaceError::with_data(
1187 "ambiguous_heading",
1188 format!(
1189 "heading {} matches {n} headings in {shown}; pass the heading's id to blocks_append",
1190 Json::String(section.to_owned())
1191 ),
1192 json!({
1193 "section": section,
1194 "path": shown,
1195 "candidates": matches.iter().map(|(b, d, _)| json!({ "id": b, "doc_id": d })).collect::<Vec<_>>(),
1196 }),
1197 )),
1198 }
1199 }
1200
1201 fn block_of_node(&self, node_id: &str) -> Result<(String, String)> {
1205 let node: Option<(String, Option<String>)> = self
1206 .store
1207 .conn()
1208 .query_row(
1209 "SELECT doc_id, block_id FROM nodes WHERE node_id = ?1",
1210 params![node_id],
1211 |r| Ok((r.get(0)?, r.get(1)?)),
1212 )
1213 .optional()?;
1214 let Some((doc_id, block_id)) = node else {
1215 return Err(SurfaceError::with_data(
1216 "block_missing",
1217 format!("no node with id {}", Json::String(node_id.to_owned())),
1218 json!({ "id": node_id }),
1219 ));
1220 };
1221 let live = match &block_id {
1222 Some(b) => self.doc_id_of_block(b)?.is_some(),
1223 None => false,
1224 };
1225 match block_id {
1226 Some(b) if live => Ok((doc_id, b)),
1227 _ => Err(node_has_no_block(node_id, &doc_id)),
1228 }
1229 }
1230
1231 fn resolve_block_ref(&self, repo_id: &str, r: &str) -> Result<String> {
1233 match read::resolve_ref(self.store.conn(), repo_id, r)? {
1234 Some(ResolvedRef::Block { block_id, .. }) => Ok(block_id),
1235 _ => Err(block_missing(r)),
1236 }
1237 }
1238
1239 fn resolve_parent_ref(&self, repo_id: &str, r: &str) -> Result<(Parent, String)> {
1241 match read::resolve_ref(self.store.conn(), repo_id, r)? {
1242 Some(ResolvedRef::Block { doc_id, block_id }) => Ok((Parent::Block(block_id), doc_id)),
1243 Some(ResolvedRef::Document { doc_id }) => Ok((Parent::Doc, doc_id)),
1244 None => Err(SurfaceError::with_data(
1245 "block_missing",
1246 format!(
1247 "no block or document with id or path {}",
1248 Json::String(r.to_owned())
1249 ),
1250 json!({ "ref": r }),
1251 )),
1252 }
1253 }
1254
1255 fn doc_id_of_block(&self, block_id: &str) -> Result<Option<String>> {
1256 Ok(self
1257 .store
1258 .conn()
1259 .query_row(
1260 "SELECT doc_id FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
1261 params![block_id],
1262 |r| r.get(0),
1263 )
1264 .optional()?)
1265 }
1266
1267 fn resolve_at(&self, repo_id: &str, at: Option<&Json>) -> Result<At> {
1269 match at {
1270 None | Some(Json::Null) => Ok(At::End),
1271 Some(Json::String(s)) if s == "start" => Ok(At::Start),
1272 Some(Json::String(s)) if s == "end" => Ok(At::End),
1273 Some(Json::Object(o)) => {
1274 if let Some(b) = o.get("before").and_then(Json::as_str) {
1275 return Ok(At::Before(self.resolve_block_ref(repo_id, b)?));
1276 }
1277 if let Some(a) = o.get("after").and_then(Json::as_str) {
1278 return Ok(At::After(self.resolve_block_ref(repo_id, a)?));
1279 }
1280 Err(bad_args(
1281 "`at` must be \"start\", \"end\", {before} or {after}",
1282 ))
1283 }
1284 Some(_) => Err(bad_args(
1285 "`at` must be \"start\", \"end\", {before} or {after}",
1286 )),
1287 }
1288 }
1289
1290 fn apply_sugar(
1298 &mut self,
1299 repo_id: &str,
1300 root: Option<&str>,
1301 effects: Vec<PublicEffect>,
1302 reason: &str,
1303 ) -> Result<Json> {
1304 let out = self.apply_sugar_with(repo_id, root, reason, |_, _| Ok(effects))?;
1305 Ok(sugar_json(&out))
1306 }
1307
1308 fn apply_sugar_with(
1315 &mut self,
1316 repo_id: &str,
1317 root: Option<&str>,
1318 reason: &str,
1319 build: impl FnOnce(&mut Store, &mut dyn DocStore) -> Result<Vec<PublicEffect>>,
1320 ) -> Result<SugarOutcome> {
1321 let origin = ApplyOrigin::new(&self.actor, Some(reason));
1322 let ctx = self.plan_ctx(repo_id);
1323 let preview = self.preview;
1324 let out = self.with_writes(root, |store, ds| {
1325 let effects = build(store, ds)?;
1326 if preview {
1327 let plan = store
1328 .plan_effects(&ctx, &effects, ds)
1329 .map_err(strip_effect_index)?;
1330 return Ok((plan, None));
1331 }
1332 let (plan, receipt) = store
1333 .apply_effects_detailed(&ctx, &effects, &origin, ds)
1334 .map_err(strip_effect_index)?;
1335 Ok((plan, Some(receipt)))
1336 })?;
1337 self.last_plan = Some(out.0.clone());
1338 if out.1.is_some() {
1339 self.notify();
1340 }
1341 Ok(out)
1342 }
1343
1344 fn doc_info(&self, repo_id: &str, doc_ref: &str) -> Result<omgbase_store::DocInfo> {
1347 self.store
1348 .find_doc_by_ref(repo_id, doc_ref)?
1349 .ok_or_else(|| {
1350 SurfaceError::from(MutationError::new(
1351 ErrorCode::DocMissing,
1352 format!("no document {doc_ref}"),
1353 ))
1354 })
1355 }
1356
1357 pub fn call_result_with_plan(
1365 &mut self,
1366 name: &str,
1367 args: &Json,
1368 ) -> Result<(Json, Option<Json>)> {
1369 self.last_plan = None;
1370 let result = self.call_result(name, args);
1371 let plan = self.last_plan.take().map(|p| plan_json(&p));
1372 Ok((result?, plan))
1373 }
1374
1375 pub fn preview_call(&mut self, name: &str, args: &Json) -> Result<Json> {
1382 self.preview = true;
1383 let out = self.call_result(name, args);
1384 self.preview = false;
1385 out
1386 }
1387
1388 fn apply_effects_detailed(
1392 &mut self,
1393 repo: &str,
1394 root: Option<&str>,
1395 args: &Json,
1396 ) -> Result<(Json, Option<Json>)> {
1397 let effects = parse_effects(&args["effects"])?;
1398 let reason = arg_str(args, "reason").map(str::to_owned);
1399 let origin = ApplyOrigin::new(&self.actor, reason.as_deref());
1400 let ctx = self.plan_ctx(repo);
1401 let preview = self.preview;
1402 let (plan, receipt) = self.with_writes(root, |store, ds| {
1403 if preview {
1404 return Ok((store.plan_effects(&ctx, &effects, ds)?, None));
1405 }
1406 let (plan, receipt) = store.apply_effects_detailed(&ctx, &effects, &origin, ds)?;
1407 Ok((plan, Some(receipt)))
1408 })?;
1409 self.last_plan = Some(plan.clone());
1410 if receipt.is_some() {
1411 self.notify();
1412 }
1413 Ok((plan_json(&plan), receipt.as_ref().map(receipt_json)))
1414 }
1415
1416 fn plan_ctx(&mut self, repo_id: &str) -> PlanContext {
1417 PlanContext {
1418 repo_id: repo_id.to_owned(),
1419 ts: self.now(),
1420 config: self.config.clone(),
1421 }
1422 }
1423
1424 #[allow(clippy::too_many_lines)]
1428 pub fn call_result(&mut self, name: &str, args: &Json) -> Result<Json> {
1429 let spec =
1432 tool_spec(name).ok_or_else(|| SurfaceError::other(format!("unknown tool {name}")))?;
1433 validate_arguments(name, &spec.input_schema, args)?;
1434 match name {
1435 "docs_outline" => {
1436 let (repo, _) = self.scope(args)?;
1437 let doc_id = self.locate_doc(&repo, name, args)?;
1438 let skeleton = match arg_str(args, "resolution") {
1439 None | Some("outline") => false,
1440 Some("skeleton") => true,
1441 Some(_) => return Err(bad_args("`resolution` must be skeleton or outline")),
1442 };
1443 read::docs_outline(
1444 &self.store,
1445 &doc_id,
1446 skeleton,
1447 arg_i64(args, "depth")?,
1448 arg_usize(args, "budget_tokens")?,
1449 )
1450 }
1451 "docs_read" => {
1452 let (repo, _) = self.scope(args)?;
1453 let doc_id = self.locate_doc(&repo, name, args)?;
1454 read::docs_read(
1455 &self.store,
1456 &doc_id,
1457 arg_bool(args, "include_ids")?.unwrap_or(false),
1458 )?
1459 .ok_or_else(|| {
1460 SurfaceError::with_data(
1461 "doc_missing",
1462 format!(
1463 "no document with id or path {}",
1464 Json::String(
1465 arg_str(args, "id")
1466 .or(arg_str(args, "path"))
1467 .unwrap_or_default()
1468 .to_owned()
1469 )
1470 ),
1471 json!({ "id": doc_id }),
1472 )
1473 })
1474 }
1475 "docs_read_many" => {
1476 let (repo, _) = self.scope(args)?;
1477 let refs = arg_strings(args, "docs")?;
1478 read::docs_read_many(
1479 &self.store,
1480 &repo,
1481 &refs,
1482 arg_bool(args, "include_ids")?.unwrap_or(false),
1483 arg_usize(args, "budget_tokens")?,
1484 )
1485 }
1486 "blocks_read" => {
1487 self.scope(args)?;
1488 let id = arg_string(args, "id")?;
1489 if id.starts_with("n_") {
1494 return Err(SurfaceError::with_data(
1495 "block_missing",
1496 format!(
1497 "no block with id {} (an n_ id names a node — nodes_block_read reads its block)",
1498 Json::String(id.clone())
1499 ),
1500 json!({ "id": id }),
1501 ));
1502 }
1503 let doc_id = self.doc_of_block(&id)?;
1504 read::blocks_read(
1505 &self.store,
1506 &doc_id,
1507 &id,
1508 resolution_arg(args, Resolution::Full)?,
1509 )?
1510 .ok_or_else(|| block_missing(&id))
1511 }
1512 "nodes_block_read" => {
1513 self.scope(args)?;
1514 let id = arg_string(args, "id")?;
1515 let (doc_id, block_id) = self.block_of_node(&id)?;
1520 read::blocks_read(
1521 &self.store,
1522 &doc_id,
1523 &block_id,
1524 resolution_arg(args, Resolution::Full)?,
1525 )?
1526 .ok_or_else(|| node_has_no_block(&id, &doc_id))
1527 }
1528 "blocks_read_many" => {
1529 self.scope(args)?;
1530 let ids = arg_strings(args, "ids")?;
1531 read::blocks_read_many(
1532 &self.store,
1533 None,
1534 &ids,
1535 resolution_arg(args, Resolution::Text)?,
1536 arg_usize(args, "budget_tokens")?,
1537 )
1538 }
1539 "docs_tree" => {
1540 let (repo, _) = self.scope(args)?;
1541 read::docs_tree(
1542 &self.store,
1543 &repo,
1544 arg_str(args, "path"),
1545 arg_i64(args, "depth")?,
1546 arg_i64(args, "limit")?,
1547 arg_str(args, "cursor"),
1548 arg_usize(args, "budget_tokens")?,
1549 )
1550 }
1551 "docs_list" => {
1552 let (repo, _) = self.scope(args)?;
1553 read::docs_list(
1554 &self.store,
1555 &repo,
1556 arg_str(args, "path_glob"),
1557 arg_i64(args, "limit")?,
1558 arg_str(args, "cursor"),
1559 arg_usize(args, "budget_tokens")?,
1560 )
1561 }
1562 "query_syntax" => Ok(json!({ "syntax": QUERY_SYNTAX })),
1563 "query" => {
1564 let (repo, _) = self.scope(args)?;
1565 let source = arg_string(args, "query")?;
1566 if self.provider.is_none()
1569 && !crate::query::collect_semantic_phrases(&source).is_empty()
1570 {
1571 return Err(provider_unavailable());
1572 }
1573 let opts = QueryOptions {
1574 limit: arg_usize(args, "limit")?,
1575 cursor: arg_str(args, "cursor"),
1576 provider: self.provider.as_deref(),
1577 in_memory: false,
1578 };
1579 self.require_rankable(
1580 &repo,
1581 !crate::query::collect_semantic_phrases(&source).is_empty(),
1582 )?;
1583 Ok(query(&self.store, &repo, &source, opts)?.to_json())
1584 }
1585 "graph" => {
1586 let (repo, _) = self.scope(args)?;
1587 let g = GraphArgs {
1588 roots: arg_strings(args, "roots")?,
1589 degrees: arg_i64(args, "degrees")?,
1590 direction: arg_str(args, "direction").map(str::to_owned),
1591 predicate: arg_str(args, "predicate").map(str::to_owned),
1592 select: if args.get("select").is_some() {
1593 arg_strings(args, "select")?
1594 } else {
1595 Vec::new()
1596 },
1597 max_documents: arg_i64(args, "max_documents")?,
1598 };
1599 if self.provider.is_none()
1600 && g.select.iter().any(|s| {
1601 !crate::query::collect_semantic_phrases(&format!("from docs select x: {s}"))
1602 .is_empty()
1603 })
1604 {
1605 return Err(provider_unavailable());
1606 }
1607 self.require_rankable(
1608 &repo,
1609 g.select.iter().any(|s| {
1610 !crate::query::collect_semantic_phrases(&format!("from docs select x: {s}"))
1611 .is_empty()
1612 }),
1613 )?;
1614 graph_neighborhood(&self.store, &repo, &g, self.provider.as_deref())
1615 }
1616 "blocks_search" => {
1617 let (repo, _) = self.scope(args)?;
1618 let q = arg_string(args, "q")?;
1619 let res =
1620 self.store
1621 .text_search(&repo, &q, arg_usize(args, "limit")?.unwrap_or(50))?;
1622 Ok(json!({
1623 "hits": res.hits.iter().map(|h| json!({
1624 "id": h.block_id, "doc_id": h.doc_id, "path": reference_path(&h.path), "type": h.block_type, "text": h.text, "score": h.score,
1625 })).collect::<Vec<_>>(),
1626 "truncated": res.truncated,
1627 }))
1628 }
1629 "blocks_resolve" => {
1630 let (repo, _) = self.scope(args)?;
1631 let q = arg_string(args, "query")?;
1632 let vector = match &self.provider {
1633 Some(p) => Some(QueryVector {
1634 model: p.model().to_owned(),
1635 vec: p
1636 .embed_query(&q)
1637 .map_err(|e| SurfaceError::new(e.code(), e.to_string()))?,
1638 }),
1639 None => None,
1640 };
1641 let hits = self
1642 .store
1643 .resolve(&repo, &q, vector, arg_usize(args, "limit")?)?;
1644 let locator = |l: &str| match l.find('#') {
1647 Some(i) => format!("{}{}", reference_path(&l[..i]), &l[i..]),
1648 None => l.to_owned(),
1649 };
1650 Ok(Json::Array(hits.iter().map(|h| json!({
1651 "id": h.id, "locator": locator(&h.locator), "preview": h.preview, "evidence": evidence_json(&h.evidence),
1652 })).collect()))
1653 }
1654 "apply" => {
1655 let (repo, root) = self.scope(args)?;
1656 self.require_root(root.as_deref())?;
1657 let (plan, receipt) = self.apply_effects_detailed(&repo, root.as_deref(), args)?;
1660 Ok(receipt.unwrap_or(plan))
1661 }
1662 "plan" => {
1663 let (repo, root) = self.scope(args)?;
1664 self.require_root(root.as_deref())?;
1665 let effects = parse_effects(&args["effects"])?;
1666 let ctx = self.plan_ctx(&repo);
1667 let plan = self.with_writes(root.as_deref(), |store, ds| {
1668 Ok(store.plan_effects(&ctx, &effects, ds)?)
1669 })?;
1670 Ok(plan_json(&plan))
1671 }
1672 "execute" => {
1673 let (repo, root) = self.scope(args)?;
1674 self.require_root(root.as_deref())?;
1675 let reason = arg_str(args, "reason").map(str::to_owned);
1676 let origin = ApplyOrigin::new(&self.actor, reason.as_deref());
1677 let ctx = self.plan_ctx(&repo);
1678 let plan_form = args.get("plan").is_some();
1680 let digest_form = args.get("plan_id").is_some() || args.get("effects").is_some();
1681 if plan_form == digest_form {
1682 return Err(argument_error(
1683 name,
1684 "pass exactly one of `plan` or `plan_id` + `effects`",
1685 &spec.input_schema,
1686 ));
1687 }
1688 let receipt = if plan_form {
1689 let plan = match Plan::admit(&args["plan"]) {
1690 Ok(plan) => plan,
1691 Err(Admission::Refused(m)) => return Err(SurfaceError::from(m)),
1692 Err(Admission::Invalid(m)) => {
1693 return Err(SurfaceError::filter_invalid(m, "arguments"));
1694 }
1695 };
1696 self.with_writes(root.as_deref(), |store, ds| {
1697 Ok(store.execute_plan(&ctx, &plan, &origin, ds)?)
1698 })?
1699 } else {
1700 let (Some(plan_id), Some(effects)) =
1701 (arg_str(args, "plan_id"), args.get("effects"))
1702 else {
1703 return Err(argument_error(
1704 name,
1705 "the digest form needs both `plan_id` and `effects`",
1706 &spec.input_schema,
1707 ));
1708 };
1709 let effects = parse_effects(effects)?;
1710 let plan_id = plan_id.to_owned();
1711 self.with_writes(root.as_deref(), |store, ds| {
1712 Ok(store.execute_by_digest(&ctx, &plan_id, &effects, &origin, ds)?)
1713 })?
1714 };
1715 self.notify();
1716 Ok(receipt_json(&receipt))
1717 }
1718 "blocks_insert" => {
1720 let (repo, root) = self.scope(args)?;
1721 self.require_root(root.as_deref())?;
1722 let (parent, doc_id) = self.resolve_parent_ref(&repo, &arg_string(args, "to")?)?;
1723 let at = self.resolve_at(&repo, args.get("at"))?;
1724 let effects = sugar::insert(
1725 placement_target(parent, doc_id),
1726 at,
1727 &arg_string(args, "markdown")?,
1728 effect_expect(arg_parent_expect(args)?),
1729 );
1730 self.apply_sugar(&repo, root.as_deref(), effects, "blocks_insert")
1731 }
1732 "blocks_update" => {
1733 let (repo, root) = self.scope(args)?;
1734 self.require_root(root.as_deref())?;
1735 let block = self.resolve_block_ref(&repo, &arg_string(args, "id")?)?;
1736 let markdown = arg_str(args, "markdown");
1737 let checked = arg_bool(args, "checked")?;
1738 let effects = sugar::update_block(
1743 &block,
1744 markdown,
1745 checked,
1746 effect_expect(arg_expect(args)?),
1747 );
1748 if effects.is_empty() {
1749 return Err(argument_error(
1750 name,
1751 "pass `markdown` and/or `checked`",
1752 &spec.input_schema,
1753 ));
1754 }
1755 self.apply_sugar(&repo, root.as_deref(), effects, "blocks_update")
1756 }
1757 "blocks_move" => {
1758 let (repo, root) = self.scope(args)?;
1759 self.require_root(root.as_deref())?;
1760 let blocks: Vec<String> =
1761 require_some(name, "blocks", arg_strings(args, "blocks")?, spec)?
1762 .iter()
1763 .map(|b| self.resolve_block_ref(&repo, b))
1764 .collect::<Result<_>>()?;
1765 let to_ref = arg_string(args, "to")?;
1766 let (parent, doc_id) = self.resolve_parent_ref(&repo, &to_ref)?;
1767 if parent == Parent::Doc {
1768 if let Some(first) = blocks.first() {
1769 if self.doc_id_of_block(first)? != Some(doc_id.clone()) {
1770 return Err(SurfaceError::with_data(
1771 "target_missing",
1772 format!(
1773 "blocks_move cannot target another document's root ({to_ref}); anchor on a block in that document with at.before/at.after"
1774 ),
1775 json!({ "to": to_ref }),
1776 ));
1777 }
1778 }
1779 }
1780 let at = self.resolve_at(&repo, args.get("at"))?;
1781 let effects = sugar::move_blocks(
1782 blocks,
1783 placement_target(parent, doc_id),
1784 at,
1785 effect_expect(arg_parent_expect(args)?),
1786 );
1787 self.apply_sugar(&repo, root.as_deref(), effects, "blocks_move")
1788 }
1789 "blocks_remove" => {
1790 let (repo, root) = self.scope(args)?;
1791 self.require_root(root.as_deref())?;
1792 let blocks: Vec<String> =
1793 require_some(name, "blocks", arg_strings(args, "blocks")?, spec)?
1794 .iter()
1795 .map(|b| self.resolve_block_ref(&repo, b))
1796 .collect::<Result<_>>()?;
1797 let effects = sugar::remove_blocks(blocks);
1798 self.apply_sugar(&repo, root.as_deref(), effects, "blocks_remove")
1799 }
1800 "blocks_split" => {
1801 let (repo, root) = self.scope(args)?;
1802 self.require_root(root.as_deref())?;
1803 let block = self.resolve_block_ref(&repo, &arg_string(args, "id")?)?;
1804 let at: Vec<usize> = args
1805 .get("at")
1806 .and_then(Json::as_array)
1807 .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))?
1808 .iter()
1809 .map(|v| {
1810 v.as_u64()
1811 .map(|n| usize::try_from(n).unwrap_or(usize::MAX))
1812 .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))
1813 })
1814 .collect::<Result<_>>()?;
1815 let effects = sugar::split_block(&block, at);
1816 self.apply_sugar(&repo, root.as_deref(), effects, "blocks_split")
1817 }
1818 "blocks_merge" => {
1819 let (repo, root) = self.scope(args)?;
1820 self.require_root(root.as_deref())?;
1821 let blocks: Vec<String> =
1822 require_some(name, "blocks", arg_strings(args, "blocks")?, spec)?
1823 .iter()
1824 .map(|b| self.resolve_block_ref(&repo, b))
1825 .collect::<Result<_>>()?;
1826 let effects = sugar::merge_blocks(blocks, arg_str(args, "separator"));
1827 self.apply_sugar(&repo, root.as_deref(), effects, "blocks_merge")
1828 }
1829 "blocks_check" => {
1830 let (repo, root) = self.scope(args)?;
1831 self.require_root(root.as_deref())?;
1832 let blocks: Vec<String> =
1833 require_some(name, "ids", arg_strings(args, "ids")?, spec)?
1834 .iter()
1835 .map(|b| self.resolve_block_ref(&repo, b))
1836 .collect::<Result<_>>()?;
1837 let effects =
1842 sugar::check_blocks(&blocks, arg_bool(args, "checked")?.unwrap_or(true));
1843 self.apply_sugar(&repo, root.as_deref(), effects, "blocks_check")
1844 }
1845 "nodes_set" => {
1846 let (repo, root) = self.scope(args)?;
1847 self.require_root(root.as_deref())?;
1848 let node_id = arg_string(args, "id")?;
1849 let known: Option<i64> = self
1850 .store
1851 .conn()
1852 .query_row(
1853 "SELECT 1 FROM nodes WHERE node_id = ?1 AND repo_id = ?2",
1854 params![node_id, repo],
1855 |r| r.get(0),
1856 )
1857 .optional()?;
1858 if known.is_none() {
1859 return Err(SurfaceError::with_data(
1860 "block_missing",
1861 format!("no node with id {}", Json::String(node_id.clone())),
1862 json!({ "id": node_id }),
1863 ));
1864 }
1865 let effects = sugar::set_node(
1866 &node_id,
1867 &arg_string(args, "prop")?,
1868 &arg_string(args, "value")?,
1869 );
1870 self.apply_sugar(&repo, root.as_deref(), effects, "nodes_set")
1871 }
1872 "blocks_append" => {
1873 let (repo, root) = self.scope(args)?;
1874 self.require_root(root.as_deref())?;
1875 let given = arg_string(args, "id")?;
1876 let block = self.resolve_block_ref(&repo, &given)?;
1877 let text = arg_string(args, "text")?;
1878 let row: Option<(String, Option<String>, i64)> = self
1882 .store
1883 .conn()
1884 .query_row(
1885 "SELECT b.type, d.format, (SELECT count(*) FROM blocks c WHERE c.parent_block = b.block_id AND c.deleted_commit IS NULL) FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ?1 AND b.deleted_commit IS NULL",
1886 params![block],
1887 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1888 )
1889 .optional()?;
1890 let Some((kind, format, children)) = row else {
1891 return Err(block_missing(&given));
1892 };
1893 let container = if format.as_deref().unwrap_or("markdown") == "markdown" {
1894 kind.parse::<BlockKind>().is_ok_and(|k| k.is_container())
1895 } else {
1896 children > 0
1897 };
1898 let into = if kind == "heading" {
1899 EffectTarget::Section { heading_id: block }
1900 } else if container {
1901 EffectTarget::Block { id: block }
1902 } else {
1903 return Err(SurfaceError::with_data(
1904 "block_not_container",
1905 format!(
1906 "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: {} }} }}",
1907 Json::String(block.clone())
1908 ),
1909 json!({ "id": block, "type": kind }),
1910 ));
1911 };
1912 let effects = sugar::insert(into, At::End, &text, None);
1913 self.apply_sugar(&repo, root.as_deref(), effects, "blocks_append")
1914 }
1915 "docs_append" => {
1916 let (repo, root) = self.scope(args)?;
1917 self.require_root(root.as_deref())?;
1918 let doc_id = self.locate_doc(&repo, name, args)?;
1919 let text = arg_string(args, "text")?;
1920 let into = match arg_str(args, "section") {
1923 Some(section) => {
1924 let info = read::find_doc_by_id(self.store.conn(), &doc_id)?
1925 .ok_or_else(|| block_missing(&doc_id))?;
1926 let heading = self.resolve_section_heading(&doc_id, &info.path, section)?;
1927 EffectTarget::Section {
1928 heading_id: heading,
1929 }
1930 }
1931 None => EffectTarget::Document {
1932 id: Some(doc_id),
1933 path: None,
1934 },
1935 };
1936 let effects = sugar::insert(into, At::End, &text, None);
1937 self.apply_sugar(&repo, root.as_deref(), effects, "docs_append")
1938 }
1939 "links_retarget" | "links_repair" => {
1940 let (repo, root) = self.scope(args)?;
1941 self.require_root(root.as_deref())?;
1942 let repairs: Vec<omgbase_store::LinkRepair> = if name == "links_retarget" {
1943 vec![omgbase_store::LinkRepair {
1944 from: arg_string(args, "from_target")?,
1945 to: arg_string(args, "to_target")?,
1946 }]
1947 } else if let Some(list) = args.get("repairs").and_then(Json::as_array) {
1948 list.iter()
1949 .map(|r| {
1950 Ok(omgbase_store::LinkRepair {
1951 from: arg_string(r, "from")?,
1952 to: arg_string(r, "to")?,
1953 })
1954 })
1955 .collect::<Result<_>>()?
1956 } else if let (Some(f), Some(t)) =
1957 (arg_str(args, "from_target"), arg_str(args, "to_target"))
1958 {
1959 vec![omgbase_store::LinkRepair {
1960 from: f.to_owned(),
1961 to: t.to_owned(),
1962 }]
1963 } else {
1964 Vec::new()
1965 };
1966 if repairs.is_empty() {
1967 return Err(SurfaceError::new(
1968 "target_missing",
1969 "links_repair requires `repairs` (array of {from,to}) or a `from_target`+`to_target` pair",
1970 ));
1971 }
1972 let path_glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
1977 let plan = self.store.links_repair(&repo, &repairs, path_glob)?;
1978 let pairs: Vec<(String, String)> = repairs
1979 .iter()
1980 .map(|r| (r.from.clone(), r.to.clone()))
1981 .collect();
1982 let effects = sugar::retarget(&pairs, path_glob);
1983 let out =
1984 self.apply_sugar_with(&repo, root.as_deref(), name, |_, _| Ok(effects))?;
1985 let mut m = Map::new();
1986 m.insert(
1987 "hits".to_owned(),
1988 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()),
1989 );
1990 m.insert(
1991 "pairs".to_owned(),
1992 Json::Array(
1993 plan.pairs
1994 .iter()
1995 .map(|p| json!({ "from": p.from, "to": p.to, "hits": p.hits }))
1996 .collect(),
1997 ),
1998 );
1999 Ok(merge(m, sugar_json(&out)))
2000 }
2001 "links_stale" => {
2002 let (repo, _) = self.scope(args)?;
2003 let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
2004 if arg_bool(args, "summary")?.unwrap_or(false) {
2005 return links::links_stale_summary(&self.store, &repo, glob);
2006 }
2007 links::links_stale(&self.store, &repo, glob, arg_i64(args, "limit")?)
2008 }
2009 "docs_create" => {
2010 let (repo, root) = self.scope(args)?;
2011 let fm = arg_object(args, "frontmatter")?.cloned();
2012 let sugar::Sugar { effects, reason } = sugar::create_document(
2013 &arg_string(args, "path")?,
2014 &arg_string(args, "markdown")?,
2015 fm.as_ref(),
2016 );
2017 let rel = canonical(&arg_string(args, "path")?);
2018 let out = self.apply_sugar_with(&repo, root.as_deref(), &reason, |store, ds| {
2022 if store.live_doc_at(&repo, &rel)? {
2023 return Err(path_taken(format!("document already exists at {rel}")));
2024 }
2025 if ds.exists(&rel) {
2026 return Err(path_taken(format!("file already exists on disk at {rel}")));
2027 }
2028 Ok(effects)
2029 })?;
2030 Ok(sugar_json(&out))
2031 }
2032 "docs_move" => {
2033 let (repo, root) = self.scope(args)?;
2034 let doc = self.locate_doc(&repo, name, args)?;
2035 let to_rel = canonical(&arg_string(args, "to_path")?);
2036 let retarget = arg_bool(args, "retarget_inbound")?.unwrap_or(true);
2038 let info = self.doc_info(&repo, &doc)?;
2039 let sugar::Sugar { effects, reason } =
2040 sugar::move_document(&info.doc_id, &info.path, &to_rel, retarget);
2041 let mut report: Option<(Vec<omgbase_store::InboundLink>, Option<Retargeted>)> =
2045 None;
2046 let out = self.apply_sugar_with(&repo, root.as_deref(), &reason, |store, ds| {
2047 if store.live_doc_at(&repo, &to_rel)? {
2048 return Err(path_taken(format!("a document already exists at {to_rel}")));
2049 }
2050 let inbound = inbound_links_to(store.conn(), &repo, &info.doc_id, &info.path)?;
2051 if ds.exists(&to_rel) {
2052 return Err(path_taken(format!(
2053 "file already exists on disk at {to_rel}"
2054 )));
2055 }
2056 report = Some(if retarget && !inbound.is_empty() {
2057 let p = store.plan_inbound_retarget(
2058 &info.doc_id,
2059 &info.path,
2060 &to_rel,
2061 &inbound,
2062 )?;
2063 (
2064 p.dangling,
2065 Some(Retargeted {
2066 blocks: p.blocks,
2067 docs: p.docs,
2068 }),
2069 )
2070 } else {
2071 (inbound, None)
2072 });
2073 Ok(effects)
2074 })?;
2075 let (dangling, retargeted) = report.expect("set before the effects were built");
2076 if out.1.is_none() {
2077 return Ok(sugar_json(&out));
2079 }
2080 let mut m = Map::new();
2086 m.insert(
2087 "retargeted".to_owned(),
2088 retargeted_json(retargeted.as_ref()),
2089 );
2090 m.insert(
2091 "dangling".to_owned(),
2092 Json::Array(dangling.iter().map(dangling_json).collect()),
2093 );
2094 let Json::Object(receipt) = sugar_json(&out) else {
2095 unreachable!("a receipt is an object")
2096 };
2097 Ok(merge(receipt, Json::Object(m)))
2098 }
2099 "docs_delete" => {
2100 let (repo, root) = self.scope(args)?;
2101 let doc = self.locate_doc(&repo, name, args)?;
2102 let info = self.doc_info(&repo, &doc)?;
2103 let sugar::Sugar { effects, reason } =
2104 sugar::delete_document(&info.doc_id, &info.path);
2105 self.apply_sugar(&repo, root.as_deref(), effects, &reason)
2106 }
2107 "docs_set_meta" => {
2108 let (repo, root) = self.scope(args)?;
2109 let doc = self.locate_doc(&repo, name, args)?;
2110 let set = arg_object(args, "set")?.cloned();
2111 let unset = if args.get("unset").is_some() {
2112 arg_strings(args, "unset")?
2113 } else {
2114 Vec::new()
2115 };
2116 let info = self.doc_info(&repo, &doc)?;
2117 let sugar::Sugar { effects, reason } =
2120 sugar::set_meta(&info.doc_id, &info.path, set.as_ref(), &unset);
2121 if effects.is_empty() {
2122 return Err(argument_error(
2127 name,
2128 "pass `set` and/or `unset`",
2129 &spec.input_schema,
2130 ));
2131 }
2132 self.apply_sugar(&repo, root.as_deref(), effects, &reason)
2133 }
2134 "docs_update" => {
2135 let (repo, root) = self.scope(args)?;
2141 self.require_root(root.as_deref())?;
2142 let doc = self.locate_doc(&repo, name, args)?;
2143 let info = self.doc_info(&repo, &doc)?;
2144 let content = arg_string(args, "content")?;
2145 let reason = arg_str(args, "reason").map(str::to_owned);
2146 let origin = ApplyOrigin::new(&self.actor, reason.as_deref());
2147 let ctx = self.plan_ctx(&repo);
2148 let effects = sugar::update_content(&info.doc_id, &content);
2149 let preview = self.preview;
2150 let (plan, receipt) = self.with_writes(root.as_deref(), |store, ds| {
2151 if preview {
2152 return Ok((store.plan_effects(&ctx, &effects, ds)?, None));
2153 }
2154 let (plan, receipt) = store
2155 .apply_effects_detailed(&ctx, &effects, &origin, ds)
2156 .map_err(strip_effect_index)?;
2157 Ok((plan, Some(receipt)))
2158 })?;
2159 self.last_plan = Some(plan.clone());
2160 if receipt.as_ref().is_some_and(|r| r.commit.is_some()) {
2161 self.notify();
2162 }
2163 Ok(sugar_json(&(plan, receipt)))
2164 }
2165 "observe" => {
2166 let (repo, _) = self.scope(args)?;
2167 let path = storage_path(&arg_string(args, "path")?).to_owned();
2168 let content = arg_string(args, "content")?;
2169 let ts = self.now();
2170 let config = self.config.clone();
2171 let out = self
2172 .store
2173 .observe_one(&repo, &path, &content, &ts, &config)?;
2174 self.store.sweep_pool(&ts)?;
2175 self.notify();
2176 Ok(observe_json(&out))
2177 }
2178 "observe_many" => {
2179 let (repo, _) = self.scope(args)?;
2180 let files = args
2181 .get("files")
2182 .and_then(Json::as_array)
2183 .ok_or_else(|| bad_args("`files` must be an array of {path, content}"))?;
2184 let items: Vec<omgbase_store::BatchItem> = files
2185 .iter()
2186 .map(|f| {
2187 Ok(omgbase_store::BatchItem::observed(
2188 storage_path(&arg_string(f, "path")?),
2189 &arg_string(f, "content")?,
2190 ))
2191 })
2192 .collect::<Result<_>>()?;
2193 let ts = self.now();
2194 let config = self.config.clone();
2195 let outcomes = self.store.observe_batch(&repo, &items, &ts, &config)?;
2196 self.store.sweep_pool(&ts)?;
2197 self.notify();
2198 let mut out = Vec::with_capacity(outcomes.len());
2199 for o in &outcomes {
2200 match o.as_observed() {
2201 Some(obs) => out.push(observe_json(obs)),
2202 None => {
2203 return Err(SurfaceError::other(format!(
2204 "observe_many: unexpected outcome for {}",
2205 o.path()
2206 )));
2207 }
2208 }
2209 }
2210 Ok(Json::Array(out))
2211 }
2212 "observe_delete" => {
2213 let (repo, _) = self.scope(args)?;
2214 let path = storage_path(&arg_string(args, "path")?).to_owned();
2215 let ts = self.now();
2216 let out = self.store.observe_delete(&repo, &path, &ts)?;
2217 self.notify();
2218 Ok(
2219 json!({ "id": out.doc_id, "path": reference_path(&out.path), "deleted": out.deleted() }),
2220 )
2221 }
2222 "blocks_history" => history::blocks_history(
2223 &self.store,
2224 &arg_string(args, "id")?,
2225 arg_i64(args, "limit")?,
2226 ),
2227 "docs_diff" => {
2228 let (repo, _) = self.scope(args)?;
2229 let doc_id = self.locate_doc(&repo, name, args)?;
2230 history::diff_blocks(
2231 &self.store,
2232 &doc_id,
2233 &arg_string(args, "from_rev")?,
2234 &arg_string(args, "to_rev")?,
2235 )
2236 }
2237 "docs_diff_unified" => {
2238 let (repo, _) = self.scope(args)?;
2239 let doc_ref = arg_str(args, "id")
2240 .or(arg_str(args, "path"))
2241 .unwrap_or_default()
2242 .to_owned();
2243 let doc_id = self.locate_doc(&repo, name, args)?;
2244 let revs = history::recent_revs(self.store.conn(), &doc_id)?;
2245 let to_rev = arg_str(args, "to_rev")
2246 .map(str::to_owned)
2247 .or_else(|| revs.first().cloned());
2248 let from_rev = arg_str(args, "from_rev")
2249 .map(str::to_owned)
2250 .or_else(|| revs.get(1).cloned())
2251 .or_else(|| revs.first().cloned());
2252 let (Some(from), Some(to)) = (from_rev, to_rev) else {
2253 return Err(SurfaceError::new(
2254 "target_missing",
2255 format!("no revisions to diff for {}", Json::String(doc_ref)),
2256 ));
2257 };
2258 let path: Option<String> = self
2259 .store
2260 .conn()
2261 .query_row(
2262 "SELECT path FROM docs WHERE doc_id = ?1",
2263 params![doc_id],
2264 |r| r.get(0),
2265 )
2266 .optional()?;
2267 let diff = history::diff_unified_text(&self.store, &doc_id, &from, &to)?;
2268 Ok(
2269 json!({ "id": doc_id, "path": path.as_deref().map_or_else(String::new, reference_path), "from": from, "to": to, "diff": diff }),
2270 )
2271 }
2272 "docs_read_at" => {
2273 let (repo, _) = self.scope(args)?;
2274 let doc_id = self.locate_doc(&repo, name, args)?;
2275 let rev = arg_string(args, "rev")?;
2276 read::docs_read_at(&self.store, &doc_id, &rev)?.ok_or_else(|| {
2277 SurfaceError::with_data(
2278 "target_missing",
2279 format!(
2280 "no revision {} for document {doc_id}",
2281 Json::String(rev.clone())
2282 ),
2283 json!({ "doc": doc_id, "rev": rev }),
2284 )
2285 })
2286 }
2287 "docs_history" => {
2288 let (repo, _) = self.scope(args)?;
2289 let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
2290 let id = arg_str(args, "id");
2291 let path = arg_str(args, "path");
2292 if glob.is_none() && id.is_none() && path.is_none() {
2293 return Err(SurfaceError::new(
2294 "target_missing",
2295 "docs_history requires one of path_glob, id or path",
2296 ));
2297 }
2298 let include_deleted = arg_bool(args, "include_deleted")?.unwrap_or(false);
2299 let doc: Option<String> = if id.is_some() || path.is_some() {
2302 let tomb: Option<String> = if include_deleted {
2303 match (id, path) {
2304 (Some(i), _) => self
2305 .store
2306 .conn()
2307 .query_row(
2308 "SELECT doc_id FROM docs WHERE doc_id = ?1 AND repo_id = ?2",
2309 params![i, repo],
2310 |r| r.get(0),
2311 )
2312 .optional()?,
2313 (None, Some(p)) => self
2314 .store
2315 .conn()
2316 .query_row(
2317 "SELECT doc_id FROM docs WHERE repo_id = ?1 AND path = ?2",
2318 params![repo, storage_path(p)],
2319 |r| r.get(0),
2320 )
2321 .optional()?,
2322 (None, None) => None,
2323 }
2324 } else {
2325 None
2326 };
2327 Some(match tomb {
2328 Some(d) => d,
2329 None => self.locate_doc(&repo, name, args)?,
2330 })
2331 } else {
2332 None
2333 };
2334 history::docs_history(
2335 &self.store,
2336 &repo,
2337 glob,
2338 doc.as_deref(),
2339 include_deleted,
2340 arg_i64(args, "limit")?,
2341 )
2342 }
2343 "changes_since" => {
2344 let (repo, _) = self.scope(args)?;
2345 let page = self.store.changes_since(
2346 &repo,
2347 arg_i64(args, "cursor")?.unwrap_or(0),
2348 arg_usize(args, "limit")?.unwrap_or(50),
2349 arg_str(args, "origin").filter(|o| !o.is_empty()),
2350 )?;
2351 Ok(root_changes_page(page.to_json()))
2352 }
2353 "repos_status" => {
2354 let (repo, root) = self.scope(args)?;
2355 let fs = RealFileSystem;
2356 let disk = root
2357 .as_deref()
2358 .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
2359 Ok(omgbase_sync::repos_status(&self.store, &repo, disk)?.to_json())
2360 }
2361 "sync_status" => {
2362 let (repo, root) = self.scope(args)?;
2363 let fs = RealFileSystem;
2364 let disk = root
2365 .as_deref()
2366 .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
2367 Ok(omgbase_sync::sync_status(&self.store, &repo, disk)?.to_json())
2368 }
2369 "repos" => {
2370 let rows = self.repo_rows()?;
2371 Ok(json!({
2372 "repos": rows.iter().map(|r| json!({ "slug": r.slug, "hasSource": r.root_path.is_some() })).collect::<Vec<_>>(),
2373 }))
2374 }
2375 "version" => version_info(Some(&self.store), &self.build_info),
2376 other => Err(SurfaceError::other(format!("unknown tool {other}"))),
2377 }
2378 }
2379}
2380
2381fn block_missing(value: &str) -> SurfaceError {
2384 SurfaceError::with_data(
2385 "block_missing",
2386 format!("no block with id {}", Json::String(value.to_owned())),
2387 json!({ "id": value }),
2388 )
2389}
2390
2391fn node_has_no_block(node_id: &str, doc_id: &str) -> SurfaceError {
2393 SurfaceError::with_data(
2394 "block_missing",
2395 format!("node {} has no block", Json::String(node_id.to_owned())),
2396 json!({ "id": node_id, "doc_id": doc_id }),
2397 )
2398}
2399
2400pub 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)";
2402
2403#[must_use]
2405pub fn provider_unavailable() -> SurfaceError {
2406 SurfaceError::with_data(
2407 "provider_unavailable",
2408 PROVIDER_UNAVAILABLE_MESSAGE,
2409 json!({ "provider": null }),
2410 )
2411}
2412
2413fn parse_effects(v: &Json) -> Result<Vec<PublicEffect>> {
2416 PublicEffect::list_from_json(v).map_err(|m| SurfaceError::filter_invalid(m, "arguments"))
2417}
2418
2419#[must_use]
2424pub fn plan_json(plan: &Plan) -> Json {
2425 let mut v = plan.to_json(true);
2426 if let Some(preview) = v.get_mut("preview").and_then(Json::as_object_mut) {
2427 if let Some(revs) = preview.get_mut("revisions").and_then(Json::as_array_mut) {
2428 for r in revs {
2429 root_field(r, "path");
2430 }
2431 }
2432 if let Some(results) = preview.get_mut("results").and_then(Json::as_array_mut) {
2436 for r in results {
2437 if let Some(o) = r.as_object_mut() {
2438 if let Some(m) = o.shift_remove("merged_into") {
2439 o.insert("mergedInto".to_owned(), m);
2440 }
2441 }
2442 }
2443 }
2444 if let Some(diffs) = preview.get_mut("diffs") {
2445 *diffs = reference_keyed(diffs);
2446 }
2447 if let Some(retargets) = preview.get_mut("retarget").and_then(Json::as_array_mut) {
2449 for r in retargets {
2450 if let Some(hits) = r.get_mut("hits").and_then(Json::as_array_mut) {
2451 for h in hits {
2452 root_field(h, "path");
2453 }
2454 }
2455 }
2456 }
2457 }
2458 v
2459}
2460
2461#[must_use]
2463pub fn receipt_json(receipt: &Receipt) -> Json {
2464 let mut v = receipt.to_json();
2465 if let Some(revs) = v.get_mut("revisions").and_then(Json::as_array_mut) {
2466 for r in revs {
2467 root_field(r, "path");
2468 }
2469 }
2470 v
2471}
2472
2473fn dangling_json(l: &omgbase_store::InboundLink) -> Json {
2477 let mut m = Map::new();
2478 m.insert("doc_id".to_owned(), json!(l.doc));
2479 m.insert("path".to_owned(), json!(reference_path(&l.path)));
2480 m.insert("block_id".to_owned(), json!(l.block));
2481 m.insert("target".to_owned(), json!(l.target));
2482 m.insert("anchor".to_owned(), json!(l.anchor));
2483 if let Some(f) = &l.field {
2484 m.insert("field".to_owned(), json!(f));
2485 }
2486 Json::Object(m)
2487}
2488
2489fn path_taken(message: String) -> SurfaceError {
2492 SurfaceError::from(MutationError::new(ErrorCode::PathTaken, message))
2493}
2494
2495fn root_changes_page(mut page: Json) -> Json {
2505 let Some(digests) = page.get_mut("digests").and_then(Json::as_array_mut) else {
2506 return page;
2507 };
2508 for d in digests.iter_mut() {
2509 if let Some(revs) = d.get_mut("revisions").and_then(Json::as_array_mut) {
2510 for r in revs.iter_mut() {
2511 root_field(r, "path");
2512 }
2513 }
2514 if let Some(summary) = d.get("summary").and_then(Json::as_str) {
2515 let rooted = root_summary(summary);
2516 d["summary"] = Json::String(rooted);
2517 }
2518 }
2519 page
2520}
2521
2522fn root_summary(summary: &str) -> String {
2524 let Some(colon) = summary.find(": ") else {
2525 return summary.to_owned();
2526 };
2527 let (head, rest) = summary.split_at(colon + 2);
2528 let (subject, detail) = match rest.find(" — ") {
2529 Some(i) => rest.split_at(i),
2530 None => (rest, ""),
2531 };
2532 let root_list = |s: &str| -> String {
2533 if s.is_empty() {
2534 return String::new();
2535 }
2536 s.split(", ")
2537 .map(reference_path)
2538 .collect::<Vec<_>>()
2539 .join(", ")
2540 };
2541 let subject = if let Some(paths) = subject.strip_prefix("deleted ") {
2542 format!("deleted {}", root_list(paths))
2543 } else if let Some(mv) = subject.strip_prefix("moved ") {
2544 let (mv, rest) = match mv.split_once("; ") {
2546 Some((mv, rest)) => (mv, Some(rest)),
2547 None => (mv, None),
2548 };
2549 let moved = match mv.split_once(" → ") {
2550 Some((from, to)) => format!("moved {} → {}", reference_path(from), reference_path(to)),
2551 None => format!("moved {mv}"),
2552 };
2553 match rest {
2554 Some(rest) => format!("{moved}; {}", root_list(rest)),
2555 None => moved,
2556 }
2557 } else {
2558 root_list(subject)
2559 };
2560 format!("{head}{subject}{detail}")
2561}
2562
2563fn evidence_json(e: &omgbase_store::Evidence) -> Json {
2566 let mut m = Map::new();
2567 m.insert("rrf".to_owned(), json!(e.rrf));
2568 m.insert("boosts".to_owned(), e.boosts.to_json());
2569 if let Some(r) = e.fts_rank {
2570 m.insert("ftsRank".to_owned(), json!(r));
2571 }
2572 if let Some(r) = e.vector_rank {
2573 m.insert("vectorRank".to_owned(), json!(r));
2574 if let Some(c) = e.cosine {
2575 m.insert("cosine".to_owned(), json!(c));
2576 }
2577 }
2578 Json::Object(m)
2579}
2580
2581fn observe_json(o: &omgbase_store::ObserveOutcome) -> Json {
2586 json!({
2587 "id": o.doc_id,
2588 "path": reference_path(&o.path),
2589 "rev": o.rev,
2590 "commitId": o.commit_id,
2591 "converged": o.converged,
2592 "echo": o.echo,
2593 "conflicted": o.conflicted,
2594 "dispositions": o.dispositions.iter().map(|(k, n)| json!({ "kind": k, "count": n })).collect::<Vec<_>>(),
2595 })
2596}
2597
2598#[cfg(test)]
2599mod sugar_tests;
2600
2601#[cfg(test)]
2602mod tests {
2603 use super::*;
2604 use omgbase_store::{MemDocStore, SequentialMinter};
2605
2606 fn surface() -> Surface {
2607 let mut store =
2608 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
2609 let repo = store.create_repo("fixture").unwrap();
2610 let mut n = 0;
2611 Surface::new(store, &repo, None)
2612 .with_doc_store(Box::new(MemDocStore::new()))
2613 .with_clock(move || {
2614 n += 1;
2615 format!("2026-09-26T10:{n:02}:00.000Z")
2616 })
2617 }
2618
2619 #[test]
2620 fn catalog_lists_every_tool_of_the_table() {
2621 let names: Vec<&str> = tools().iter().map(|t| t.name).collect();
2622 for want in [
2623 "docs_outline",
2624 "docs_read",
2625 "docs_read_many",
2626 "blocks_read",
2627 "nodes_block_read",
2628 "blocks_read_many",
2629 "docs_tree",
2630 "docs_list",
2631 "query_syntax",
2632 "query",
2633 "graph",
2634 "blocks_search",
2635 "blocks_resolve",
2636 "apply",
2637 "plan",
2638 "execute",
2639 "blocks_insert",
2640 "blocks_update",
2641 "blocks_move",
2642 "blocks_remove",
2643 "blocks_split",
2644 "blocks_merge",
2645 "blocks_check",
2646 "nodes_set",
2647 "blocks_append",
2648 "docs_append",
2649 "links_retarget",
2650 "links_stale",
2651 "links_repair",
2652 "docs_create",
2653 "docs_move",
2654 "docs_delete",
2655 "docs_set_meta",
2656 "docs_update",
2657 "observe",
2658 "observe_many",
2659 "observe_delete",
2660 "blocks_history",
2661 "docs_diff",
2662 "docs_diff_unified",
2663 "docs_read_at",
2664 "docs_history",
2665 "changes_since",
2666 "repos_status",
2667 "sync_status",
2668 "repos",
2669 "version",
2670 ] {
2671 assert!(names.contains(&want), "missing {want}");
2672 }
2673 assert_eq!(names.len(), 47);
2674 for t in tools() {
2675 assert_eq!(t.input_schema["type"], "object");
2676 }
2677 }
2678
2679 #[test]
2684 fn plan_execute_and_apply_effects_round_trip() {
2685 let mut s = surface();
2686 let out = s.call(
2687 "observe",
2688 json!({ "path": "a.md", "content": "---\nstatus: open\n---\n\n# A\n\nAlpha.\n" }),
2689 );
2690 assert!(!out.is_error, "{}", out.body);
2691 let effects = json!([
2692 { "op": "set", "target": { "kind": "document", "path": "/a.md" }, "field": "frontmatter.status", "value": "done" },
2693 { "op": "insert", "into": { "kind": "section", "heading_id": "b_0" }, "markdown": "Tail." },
2694 ]);
2695 let plan = s.call("plan", json!({ "effects": effects }));
2696 assert!(!plan.is_error, "{}", plan.body);
2697 let plan = plan.body;
2698 assert_eq!(plan["version"], 1);
2699 assert_eq!(plan["plan_id"].as_str().map(str::len), Some(64));
2700 assert_eq!(
2701 plan["effects"][0]["target"],
2702 json!({ "kind": "document", "id": "d_0", "path": "a.md" }),
2703 "the core keeps storage paths"
2704 );
2705 assert_eq!(
2706 plan["preview"]["revisions"][0]["path"], "/a.md",
2707 "the preview is rooted"
2708 );
2709 assert!(
2710 plan["preview"]["diffs"].get("/a.md").is_some(),
2711 "{}",
2712 plan["preview"]["diffs"]
2713 );
2714 assert_eq!(
2715 plan["new_ids"],
2716 json!([{ "symbol": "$new.0", "kind": "block" }])
2717 );
2718 let dry = s.call("apply", json!({ "effects": effects, "dry_run": true }));
2721 assert_eq!(dry.body["error"], "filter_invalid", "{}", dry.body);
2722 assert_eq!(
2723 dry.body["message"],
2724 "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (apply takes effects, reason?, repo?)"
2725 );
2726 let ops = s.call("apply", json!({ "ops": [] }));
2727 assert_eq!(ops.body["error"], "filter_invalid");
2728 assert_eq!(
2729 ops.body["message"],
2730 "`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, reason?, repo?)"
2731 );
2732 assert_eq!(ops.body["data"]["hint"], "arguments");
2733 let neither = s.call("apply", json!({}));
2734 assert_eq!(neither.body["error"], "filter_invalid");
2735 assert_eq!(
2736 neither.body["message"],
2737 "`effects` is required (apply takes effects, reason?, repo?)"
2738 );
2739 let previewed = s
2742 .preview_call("apply", &json!({ "effects": effects }))
2743 .unwrap();
2744 assert_eq!(previewed["plan_id"], plan["plan_id"]);
2745 assert!(previewed.get("preview").is_some());
2746 let bad = s.call("apply", json!({ "effects": [{ "op": "nope" }] }));
2747 assert!(bad.is_error);
2748 assert_eq!(bad.body["error"], "filter_invalid");
2749 assert_eq!(bad.body["data"]["hint"], "arguments");
2750 assert!(
2751 bad.body["message"]
2752 .as_str()
2753 .unwrap()
2754 .starts_with("effects[0]:"),
2755 "{}",
2756 bad.body
2757 );
2758 let empty = s.call("execute", json!({}));
2760 assert!(empty.is_error);
2761 assert_eq!(empty.body["data"]["hint"], "arguments");
2762 let mut echoed = plan.clone();
2764 echoed.as_object_mut().unwrap().remove("preview");
2765 let receipt = s.call("execute", json!({ "plan": echoed, "reason": "slice 1" }));
2766 assert!(!receipt.is_error, "{}", receipt.body);
2767 assert_eq!(receipt.body["plan_id"], plan["plan_id"]);
2768 assert_eq!(receipt.body["committed"], true);
2769 assert_eq!(receipt.body["commit"], "c_1");
2770 assert_eq!(receipt.body["revisions"][0]["path"], "/a.md");
2771 assert_eq!(receipt.body["new_ids"], json!({ "$new.0": "b_2" }));
2772 let read = s.call("docs_read", json!({ "path": "a.md" }));
2773 assert_eq!(
2774 read.body["raw"],
2775 "---\nstatus: done\n---\n\n# A\n\nAlpha.\n\nTail.\n"
2776 );
2777 let again = s.call("execute", json!({ "plan": echoed }));
2779 assert!(again.is_error);
2780 assert_eq!(again.body["error"], "stale_plan");
2781 assert_eq!(again.body["retriable"], true);
2782 let digest = s.call(
2783 "execute",
2784 json!({ "plan_id": plan["plan_id"], "effects": effects }),
2785 );
2786 assert!(digest.is_error);
2787 assert_eq!(digest.body["error"], "plan_mismatch", "{}", digest.body);
2788 let half = s.call("execute", json!({ "plan_id": plan["plan_id"] }));
2789 assert!(half.is_error);
2790 assert!(
2791 half.body["message"]
2792 .as_str()
2793 .unwrap()
2794 .starts_with("the digest form needs both"),
2795 "{}",
2796 half.body
2797 );
2798 let v2 = s.call(
2799 "execute",
2800 json!({ "plan": { "version": 2, "plan_id": "x" } }),
2801 );
2802 assert_eq!(v2.body["error"], "unsupported_plan_version", "{}", v2.body);
2803 let ops_count: i64 = s
2804 .store()
2805 .conn()
2806 .query_row(
2807 "SELECT count(*) FROM commits WHERE ops IS NOT NULL",
2808 [],
2809 |r| r.get(0),
2810 )
2811 .unwrap();
2812 assert_eq!(ops_count, 1, "the effect commit recorded its intent");
2813 assert!(Surface::is_write_tool("plan") && Surface::is_write_tool("execute"));
2814 }
2815
2816 #[test]
2817 fn observe_read_and_query_round_trip() {
2818 let mut s = surface();
2819 let out = s.call("observe", json!({ "path": "a.md", "content": "---\nlayer: canon\n---\n# Title\n\nHello world.\n\n- [ ] task one\n" }));
2820 assert!(!out.is_error, "{}", out.body);
2821 assert_eq!(out.body["id"], "d_0");
2822 assert_eq!(out.body["echo"], false);
2823 let read = s.call("docs_read", json!({ "path": "a.md", "include_ids": true }));
2824 assert!(!read.is_error, "{}", read.body);
2825 assert_eq!(read.body["path"], "/a.md");
2826 assert_eq!(
2828 s.call("docs_read", json!({ "path": "/a.md" })).body["path"],
2829 "/a.md"
2830 );
2831 let retired = s.call("docs_read", json!({ "doc": "a.md" }));
2833 assert_eq!(retired.body["error"], "filter_invalid");
2834 assert_eq!(
2835 retired.body["message"],
2836 "`doc` is no longer an argument — pass `id` or `path` (docs_read takes id?, include_ids?, path?, repo?)"
2837 );
2838 let neither = s.call("docs_read", json!({}));
2839 assert_eq!(
2840 neither.body["message"],
2841 "pass id or path (docs_read takes id?, include_ids?, path?, repo?)"
2842 );
2843 assert_eq!(
2844 s.call("docs_read", json!({ "path": "//a.md" })).body["id"],
2845 "d_0"
2846 );
2847 assert_eq!(read.body["properties"]["frontmatter"]["layer"], "canon");
2848 assert!(read.body["ids"].as_array().unwrap().len() >= 3);
2849 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" }));
2850 assert!(!q.is_error, "{}", q.body);
2851 assert_eq!(q.body["hits"][0]["title"], "Title");
2852 assert_eq!(q.body["hits"][0]["layer"], "canon");
2853 assert_eq!(q.body["hits"][0]["t"][0]["value"], "task one");
2854 assert_eq!(q.body["hits"][0]["id"], "d_0");
2855 assert_eq!(q.body["hits"][0]["path"], "/a.md");
2856 assert_eq!(q.body["consumer"], "collect");
2857 let c = s.call(
2858 "query",
2859 json!({ "query": "blocks count { where text(\"hello\") }" }),
2860 );
2861 assert_eq!(c.body["count"], 1);
2862 let ok = s.call(
2865 "query",
2866 json!({ "query": "from docs where path == \"a.md\"" }),
2867 );
2868 assert!(!ok.is_error, "{}", ok.body);
2869 assert_eq!(ok.body["hits"][0]["id"], "d_0");
2870 let bad = s.call("query", json!({ "query": "select data.path from docs" }));
2871 assert!(bad.is_error);
2872 assert_eq!(bad.body["error"], "filter_invalid");
2873 assert_eq!(
2874 bad.body["message"],
2875 "projection `path` collides with the hit envelope — alias it (`p: data.path`)"
2876 );
2877 let sem = s.call(
2878 "query",
2879 json!({ "query": "from docs where semantic(\"x\") > 0.5" }),
2880 );
2881 assert_eq!(sem.body["error"], "provider_unavailable");
2882 assert_eq!(sem.body["data"], json!({ "provider": null }));
2883 let outline = s.call("docs_outline", json!({ "path": "a.md" }));
2884 let text = outline.body["text"].as_str().unwrap();
2885 assert!(text.starts_with("b_0 h1 Title §"), "{text}");
2886 assert!(text.contains("☐ task one"));
2887 let missing = s.call("docs_read", json!({ "path": "nope.md" }));
2888 assert_eq!(missing.body["error"], "doc_missing");
2889 assert_eq!(
2890 missing.body["message"],
2891 "no document with id or path \"nope.md\""
2892 );
2893 assert_eq!(missing.body["data"], json!({ "path": "nope.md" }));
2894 let repos = s.call("repos", json!({}));
2895 assert_eq!(repos.body["repos"][0]["slug"], "fixture");
2896 let unknown = s.call("docs_list", json!({ "repo": "zzz" }));
2897 assert_eq!(unknown.body["error"], "repo_not_found");
2898 }
2899
2900 #[test]
2901 fn insert_and_move_carry_the_destination_parent_cas_and_many_reads_keep_one_item() {
2902 let mut s = surface();
2903 s.call(
2904 "observe",
2905 json!({ "path": "a.md", "content": "# T\n\nOne.\n\nTwo.\n" }),
2906 );
2907 s.call("observe", json!({ "path": "b.md", "content": "# U\n" }));
2908 let stale = s.call(
2912 "blocks_insert",
2913 json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": "00" } }),
2914 );
2915 assert!(stale.is_error, "{}", stale.body);
2916 assert_eq!(stale.body["error"], "stale_expectation");
2917 assert_eq!(stale.body["retriable"], true);
2918 let current = stale.body["data"]["current"]["parent_children_hash"]
2919 .as_str()
2920 .expect("current hash")
2921 .to_owned();
2922 assert_eq!(current.len(), 64, "{current}");
2923 let ok = s.call(
2924 "blocks_insert",
2925 json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": current, "content_hash": "dropped, not checked" } }),
2926 );
2927 assert!(!ok.is_error, "{}", ok.body);
2928 assert_eq!(ok.body["committed"], true);
2929 let read = s.call("docs_read", json!({ "path": "a.md" }));
2930 assert_eq!(read.body["raw"], "# T\n\nOne.\n\nTwo.\n\nThree.\n");
2931 let stale = s.call(
2933 "blocks_move",
2934 json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": "00" } }),
2935 );
2936 assert_eq!(stale.body["error"], "stale_expectation", "{}", stale.body);
2937 let current = stale.body["data"]["current"]["parent_children_hash"]
2938 .as_str()
2939 .expect("current hash")
2940 .to_owned();
2941 let ok = s.call(
2942 "blocks_move",
2943 json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": current } }),
2944 );
2945 assert!(!ok.is_error, "{}", ok.body);
2946 let read = s.call("docs_read", json!({ "path": "a.md" }));
2947 assert_eq!(read.body["raw"], "# T\n\nThree.\n\nOne.\n\nTwo.\n\n");
2948 let dry = s.call(
2950 "plan",
2951 json!({ "effects": [{ "op": "move", "targets": [{ "kind": "block", "id": "b_1" }], "into": { "kind": "document", "path": "a.md" }, "at": "start", "expect": { "parent_children_hash": "00" } }] }),
2952 );
2953 assert_eq!(dry.body["error"], "stale_expectation", "{}", dry.body);
2954 let dry = s
2955 .preview_call(
2956 "blocks_move",
2957 &json!({ "blocks": ["b_1"], "to": "a.md", "at": "start", "expect": { "parent_children_hash": "00" } }),
2958 )
2959 .unwrap_err();
2960 assert_eq!(dry.code, "stale_expectation");
2961 let many = s.call(
2964 "docs_read_many",
2965 json!({ "docs": ["a.md", "b.md"], "budget_tokens": 1 }),
2966 );
2967 assert_eq!(
2968 many.body["items"].as_array().unwrap().len(),
2969 1,
2970 "{}",
2971 many.body
2972 );
2973 assert_eq!(many.body["items"][0]["path"], "/a.md");
2974 assert_eq!(many.body["truncated"], true);
2975 let one = s.call(
2976 "docs_read_many",
2977 json!({ "docs": ["b.md"], "budget_tokens": 1 }),
2978 );
2979 assert_eq!(one.body["items"].as_array().unwrap().len(), 1);
2980 assert_eq!(one.body["truncated"], false);
2981 let nodes = s.call(
2982 "blocks_read_many",
2983 json!({ "ids": ["b_zzz", "b_1", "b_2"], "resolution": "full", "budget_tokens": 1 }),
2984 );
2985 assert_eq!(
2986 nodes.body["nodes"].as_array().unwrap().len(),
2987 1,
2988 "{}",
2989 nodes.body
2990 );
2991 assert_eq!(nodes.body["nodes"][0]["id"], "b_1");
2992 assert_eq!(nodes.body["truncated"], true);
2993 assert_eq!(nodes.body["unresolved"], json!(["b_zzz"]));
2994 }
2995
2996 #[test]
2997 fn writes_go_through_the_fixed_doc_store_and_fire_the_hook() {
2998 use std::cell::Cell;
2999 use std::rc::Rc;
3000 let fired = Rc::new(Cell::new(0));
3001 let f2 = Rc::clone(&fired);
3002 let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
3003 s.call(
3004 "observe",
3005 json!({ "path": "a.md", "content": "# T\n\nOne.\n" }),
3006 );
3007 assert_eq!(fired.get(), 1);
3008 let dry = s
3009 .preview_call(
3010 "blocks_insert",
3011 &json!({ "to": "a.md", "markdown": "Two." }),
3012 )
3013 .unwrap();
3014 assert!(dry.get("committed").is_none(), "a Plan: {dry}");
3015 assert_eq!(
3016 dry["new_ids"],
3017 json!([{ "symbol": "$new.0", "kind": "block" }])
3018 );
3019 assert_eq!(fired.get(), 1, "a preview never fires the hook");
3020 let plan = s.call("plan", json!({ "effects": [{ "op": "insert", "into": { "kind": "document", "path": "a.md" }, "markdown": "Two." }] }));
3021 assert_eq!(
3022 plan.body["plan_id"], dry["plan_id"],
3023 "the preview IS the plan"
3024 );
3025 assert_eq!(fired.get(), 1, "`plan` never fires the hook");
3026 let wet = s.call("blocks_insert", json!({ "to": "a.md", "markdown": "Two." }));
3027 assert!(!wet.is_error, "{}", wet.body);
3028 assert_eq!(fired.get(), 2);
3029 assert_eq!(wet.body["plan_id"], dry["plan_id"]);
3031 assert_eq!(wet.body["committed"], true);
3032 assert_eq!(wet.body["commit"], "c_1");
3033 assert_eq!(wet.body["new_ids"], json!({ "$new.0": "b_2" }));
3034 assert_eq!(wet.body["revisions"][0]["path"], "/a.md");
3035 assert!(wet.body.get("results").is_none() && wet.body.get("diffs").is_none());
3036 let read = s.call("docs_read", json!({ "id": "d_0" }));
3037 assert_eq!(read.body["raw"], "# T\n\nOne.\n\nTwo.\n");
3038 let upd = s.call(
3039 "blocks_update",
3040 json!({ "id": "b_1", "markdown": "One, edited." }),
3041 );
3042 assert!(!upd.is_error, "{}", upd.body);
3043 assert!(
3044 upd.body.get("id").is_none() && upd.body.get("ids").is_none(),
3045 "{}",
3046 upd.body
3047 );
3048 assert_eq!(upd.body["revisions"].as_array().map(Vec::len), Some(1));
3049 let attrs = s.call(
3050 "blocks_update",
3051 json!({ "id": "b_1", "attrs": { "checked": true } }),
3052 );
3053 assert_eq!(
3054 attrs.body["message"],
3055 "`attrs` is no longer an argument — pass `checked` (blocks_update takes checked?, expect?, id, markdown?, repo?)"
3056 );
3057 let dry = s.call(
3058 "blocks_update",
3059 json!({ "id": "b_1", "markdown": "x", "dry_run": true }),
3060 );
3061 assert_eq!(
3062 dry.body["message"],
3063 "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (blocks_update takes checked?, expect?, id, markdown?, repo?)"
3064 );
3065 let nothing = s.call("blocks_update", json!({ "id": "b_1" }));
3066 assert_eq!(
3067 nothing.body["message"],
3068 "pass `markdown` and/or `checked` (blocks_update takes checked?, expect?, id, markdown?, repo?)"
3069 );
3070 let app = s.call(
3071 "docs_append",
3072 json!({ "path": "a.md", "section": "T", "text": "Three." }),
3073 );
3074 assert!(!app.is_error, "{}", app.body);
3075 let amb = s.call(
3076 "docs_append",
3077 json!({ "path": "a.md", "section": "Nope", "text": "x" }),
3078 );
3079 assert_eq!(amb.body["error"], "parent_missing");
3080 assert_eq!(amb.body["message"], "no heading \"Nope\" in /a.md");
3081 let into = s.call("blocks_append", json!({ "id": "b_0", "text": "Four." }));
3083 assert!(!into.is_error, "{}", into.body);
3084 let leaf = s.call("blocks_append", json!({ "id": "b_1", "text": "x" }));
3085 assert_eq!(leaf.body["error"], "block_not_container", "{}", leaf.body);
3086 assert_eq!(
3087 leaf.body["data"],
3088 json!({ "id": "b_1", "type": "paragraph" })
3089 );
3090 let hist = s.call("blocks_history", json!({ "id": "b_1" }));
3091 let entries = hist.body.as_array().unwrap();
3092 assert!(entries.len() >= 2, "{}", hist.body);
3093 assert_eq!(entries[0]["origin"], "api", "newest first");
3094 let du = s.call("docs_diff_unified", json!({ "path": "a.md" }));
3095 assert!(!du.is_error, "{}", du.body);
3096 assert!(du.body["diff"].as_str().unwrap().contains("+Four."));
3097 }
3098
3099 #[test]
3106 fn doc_tools_return_receipts_and_preview_through_plan() {
3107 use std::cell::Cell;
3108 use std::rc::Rc;
3109 let fired = Rc::new(Cell::new(0));
3110 let f2 = Rc::clone(&fired);
3111 let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
3112 let b_src = "# B\n\nTarget.\n";
3113 let a_src = "# A\n\nSee [b](b.md).\n";
3114 let b = s.call("docs_create", json!({ "path": "b.md", "markdown": b_src }));
3115 assert!(!b.is_error, "{}", b.body);
3116 assert_eq!(
3117 b.body,
3118 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 }),
3119 "the created document is revisions[0], its symbols the document then its blocks"
3120 );
3121 let a = s.call("docs_create", json!({ "path": "a.md", "markdown": a_src }));
3122 assert_eq!(a.body["new_ids"]["$new.0"], "d_1", "{}", a.body);
3123 assert_eq!(fired.get(), 2);
3124 let count = |s: &Surface, table: &str| -> i64 {
3125 s.store
3126 .conn()
3127 .query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
3128 .unwrap()
3129 };
3130 let snapshot = |s: &Surface| {
3131 (
3132 count(s, "docs"),
3133 count(s, "commits"),
3134 count(s, "revisions"),
3135 count(s, "blocks"),
3136 )
3137 };
3138 let before = snapshot(&s);
3139 let keys = |v: &Json| -> Vec<String> { v.as_object().unwrap().keys().cloned().collect() };
3140
3141 let c = s
3143 .preview_call(
3144 "docs_create",
3145 &json!({ "path": "c.md", "markdown": "# C", "frontmatter": { "title": "C" } }),
3146 )
3147 .unwrap();
3148 assert_eq!(
3149 c["preview"]["diffs"],
3150 json!({ "/c.md": { "before": "", "after": "---\ntitle: C\n---\n\n# C\n" } })
3151 );
3152 assert_eq!(c["preview"]["revisions"][0]["id"], "$new.0");
3153 assert_eq!(
3154 c["new_ids"],
3155 json!([{ "symbol": "$new.0", "kind": "document" }, { "symbol": "$new.1", "kind": "block" }])
3156 );
3157
3158 let m = s
3161 .preview_call(
3162 "docs_move",
3163 &json!({ "path": "b.md", "to_path": "notes/b.md", "retarget_inbound": true }),
3164 )
3165 .unwrap();
3166 assert_eq!(
3167 keys(&m["preview"]["diffs"]),
3168 ["/a.md", "/b.md", "/notes/b.md"]
3169 );
3170 assert_eq!(
3171 m["preview"]["diffs"]["/a.md"],
3172 json!({ "before": a_src, "after": "# A\n\nSee [b](notes/b.md).\n" })
3173 );
3174 assert_eq!(
3175 m["preview"]["diffs"]["/b.md"],
3176 json!({ "before": b_src, "after": "" })
3177 );
3178 assert_eq!(
3179 m["preview"]["diffs"]["/notes/b.md"],
3180 json!({ "before": "", "after": b_src })
3181 );
3182 assert!(
3183 m.get("dangling").is_none(),
3184 "a preview is the Plan alone: {m}"
3185 );
3186 let plain = s
3188 .preview_call(
3189 "docs_move",
3190 &json!({ "path": "b.md", "to_path": "notes/b.md", "retarget_inbound": false }),
3191 )
3192 .unwrap();
3193 assert_eq!(keys(&plain["preview"]["diffs"]), ["/b.md", "/notes/b.md"]);
3194
3195 let d = s
3197 .preview_call("docs_delete", &json!({ "path": "a.md" }))
3198 .unwrap();
3199 assert_eq!(
3200 d["preview"]["diffs"],
3201 json!({ "/a.md": { "before": a_src, "after": "" } })
3202 );
3203
3204 let sm = s
3206 .preview_call(
3207 "docs_set_meta",
3208 &json!({ "path": "a.md", "set": { "status": "open" } }),
3209 )
3210 .unwrap();
3211 assert_eq!(
3212 sm["preview"]["diffs"],
3213 json!({ "/a.md": { "before": a_src, "after": "---\nstatus: open\n---\n\n# A\n\nSee [b](b.md).\n" } })
3214 );
3215
3216 assert_eq!(fired.get(), 2, "previews never fire the mutation hook");
3219 assert_eq!(snapshot(&s), before);
3220 let taken = s.preview_call("docs_create", &json!({ "path": "a.md", "markdown": "x" }));
3221 assert_eq!(taken.unwrap_err().code, "path_taken");
3222 let missing = s.preview_call("docs_delete", &json!({ "path": "nope.md" }));
3223 assert_eq!(missing.unwrap_err().code, "doc_missing");
3224 let dry = s.call("docs_delete", json!({ "path": "a.md", "dry_run": true }));
3225 assert_eq!(
3226 dry.body["message"],
3227 "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (docs_delete takes id?, path?, repo?)"
3228 );
3229 let real = s.call(
3230 "docs_create",
3231 json!({ "path": "c.md", "markdown": "# C\n" }),
3232 );
3233 assert_eq!(
3234 real.body["new_ids"]["$new.0"], "d_2",
3235 "the preview burned no `d`"
3236 );
3237 assert_eq!(fired.get(), 3);
3238
3239 let moved = s.call(
3242 "docs_move",
3243 json!({ "path": "b.md", "to_path": "notes/b.md" }),
3244 );
3245 assert!(!moved.is_error, "{}", moved.body);
3246 assert_eq!(
3247 keys(&moved.body),
3248 [
3249 "plan_id",
3250 "commit",
3251 "revisions",
3252 "new_ids",
3253 "committed",
3254 "retargeted",
3255 "dangling"
3256 ]
3257 );
3258 assert_eq!(
3259 moved.body["revisions"],
3260 json!([{ "id": "d_1", "path": "/a.md", "hash": moved.body["revisions"][0]["hash"] }])
3261 );
3262 assert_eq!(moved.body["dangling"], json!([]));
3263 assert_eq!(
3264 moved.body["retargeted"],
3265 json!({ "blocks": ["b_3"], "docs": ["d_1"] })
3266 );
3267 assert_eq!(
3268 s.call("docs_read", json!({ "id": "d_0" })).body["path"],
3269 "/notes/b.md"
3270 );
3271 let back = s.call(
3273 "docs_move",
3274 json!({ "path": "notes/b.md", "to_path": "b.md", "retarget_inbound": false }),
3275 );
3276 assert_eq!(back.body["revisions"], json!([]), "{}", back.body);
3277 assert_eq!(
3278 back.body["dangling"],
3279 json!([{ "doc_id": "d_1", "path": "/a.md", "block_id": "b_3", "target": "notes/b.md", "anchor": null }])
3280 );
3281 assert_eq!(back.body["retargeted"], Json::Null);
3282 let sm = s.call(
3284 "docs_set_meta",
3285 json!({ "path": "a.md", "set": { "status": "open" } }),
3286 );
3287 assert_eq!(
3288 sm.body["revisions"].as_array().map(Vec::len),
3289 Some(1),
3290 "{}",
3291 sm.body
3292 );
3293 assert_eq!(sm.body["committed"], true);
3294 let d = s.call("docs_delete", json!({ "path": "a.md" }));
3295 assert_eq!(d.body["revisions"], json!([]), "{}", d.body);
3296 assert!(d.body["commit"].is_string());
3297 assert_eq!(
3298 s.call("docs_read", json!({ "path": "a.md" })).body["error"],
3299 "doc_missing"
3300 );
3301 }
3302
3303 #[test]
3304 fn change_summaries_are_rooted_like_the_reference_renders_them() {
3305 assert_eq!(
3306 root_summary("observed: a.md, b/c.md — 3 inserted, 1 edited"),
3307 "observed: /a.md, /b/c.md — 3 inserted, 1 edited"
3308 );
3309 assert_eq!(
3310 root_summary("observed: deleted a.md"),
3311 "observed: deleted /a.md"
3312 );
3313 assert_eq!(
3314 root_summary("api(human:spec): moved a.md → b/a.md"),
3315 "api(human:spec): moved /a.md → /b/a.md"
3316 );
3317 assert_eq!(
3318 root_summary("api(human:spec): moved a.md → b/a.md; index.md, c.md — 31 edited"),
3319 "api(human:spec): moved /a.md → /b/a.md; /index.md, /c.md — 31 edited"
3320 );
3321 assert_eq!(root_summary("api(?): x.md"), "api(?): /x.md");
3322 assert_eq!(root_summary("observed: "), "observed: ");
3323 assert_eq!(root_summary("nonsense"), "nonsense");
3324 let page = root_changes_page(json!({
3325 "digests": [{ "summary": "observed: a.md", "revisions": [{ "id": "d_0", "path": "a.md", "hash": "ff" }] }],
3326 "cursor": 1, "truncated": false, "head": 1
3327 }));
3328 assert_eq!(page["digests"][0]["revisions"][0]["path"], "/a.md");
3329 assert_eq!(page["digests"][0]["summary"], "observed: /a.md");
3330 }
3331}