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