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