1use std::path::Path;
9
10use omgbase_format::BlockKind;
11use omgbase_format::text::{normalize_text, normalize_visible_text};
12use omgbase_reconcile::Config;
13use omgbase_search::EmbeddingProvider;
14use omgbase_store::mutate_kernel::{At, Op, Parent, To};
15use omgbase_store::{
16 ApplyOrigin, ApplyRequest, ApplyResult, DocOpContext, DocStore, Expect, FsDocStore, Opset,
17 QueryVector, Store,
18};
19use omgbase_sync::{RealFileSystem, RepoRow};
20use rusqlite::{OptionalExtension, params};
21use serde_json::{Map, Value as Json, json};
22
23use crate::error::{Result, SurfaceError};
24use crate::graph::{GraphArgs, graph_neighborhood};
25use crate::history;
26use crate::links;
27use crate::query::{QueryOptions, query};
28use crate::read::{self, Resolution, ResolvedRef};
29use crate::reference::QUERY_SYNTAX;
30
31pub const ACTOR: &str = "agent:mcp";
33
34#[derive(Clone, Debug, PartialEq)]
36pub struct ToolSpec {
37 pub name: &'static str,
38 pub description: &'static str,
39 pub input_schema: Json,
41}
42
43#[derive(Clone, Debug, PartialEq)]
46pub struct ToolOutcome {
47 pub body: Json,
48 pub is_error: bool,
49}
50
51enum WriteTarget {
53 Derived,
56 Fixed(Box<dyn DocStore>),
59}
60
61pub struct Surface {
63 store: Store,
64 default_repo: String,
65 provider: Option<Box<dyn EmbeddingProvider>>,
66 on_mutation: Option<Box<dyn FnMut()>>,
67 clock: Box<dyn FnMut() -> String>,
68 writes: WriteTarget,
69 config: Config,
70}
71
72fn bad_args(msg: impl Into<String>) -> SurfaceError {
75 SurfaceError::filter_invalid(msg, "arguments")
76}
77
78fn arg_str<'a>(args: &'a Json, key: &str) -> Option<&'a str> {
79 args.get(key).and_then(Json::as_str)
80}
81
82fn arg_string(args: &Json, key: &str) -> Result<String> {
83 arg_str(args, key)
84 .map(str::to_owned)
85 .ok_or_else(|| bad_args(format!("`{key}` must be a string")))
86}
87
88fn arg_i64(args: &Json, key: &str) -> Result<Option<i64>> {
89 match args.get(key) {
90 None | Some(Json::Null) => Ok(None),
91 Some(v) => v
92 .as_f64()
93 .filter(|n| n.fract() == 0.0)
94 .map(|n| Some(n as i64))
95 .ok_or_else(|| bad_args(format!("`{key}` must be an integer"))),
96 }
97}
98
99fn arg_usize(args: &Json, key: &str) -> Result<Option<usize>> {
100 Ok(arg_i64(args, key)?.map(|n| usize::try_from(n).unwrap_or(0)))
101}
102
103fn arg_bool(args: &Json, key: &str) -> Result<Option<bool>> {
104 match args.get(key) {
105 None | Some(Json::Null) => Ok(None),
106 Some(Json::Bool(b)) => Ok(Some(*b)),
107 Some(_) => Err(bad_args(format!("`{key}` must be a boolean"))),
108 }
109}
110
111fn arg_strings(args: &Json, key: &str) -> Result<Vec<String>> {
112 let Some(arr) = args.get(key).and_then(Json::as_array) else {
113 return Err(bad_args(format!("`{key}` must be an array of strings")));
114 };
115 arr.iter()
116 .map(|v| {
117 v.as_str()
118 .map(str::to_owned)
119 .ok_or_else(|| bad_args(format!("`{key}` must be an array of strings")))
120 })
121 .collect()
122}
123
124fn arg_object<'a>(args: &'a Json, key: &str) -> Result<Option<&'a Map<String, Json>>> {
125 match args.get(key) {
126 None | Some(Json::Null) => Ok(None),
127 Some(Json::Object(o)) => Ok(Some(o)),
128 Some(_) => Err(bad_args(format!("`{key}` must be an object"))),
129 }
130}
131
132fn resolution_arg(args: &Json, default: Resolution) -> Result<Resolution> {
133 match arg_str(args, "resolution") {
134 None => Ok(default),
135 Some(s) => Resolution::parse(s).ok_or_else(|| {
136 bad_args("`resolution` must be one of skeleton, outline, text, raw, full")
137 }),
138 }
139}
140
141fn merge(mut a: Map<String, Json>, b: Json) -> Json {
143 if let Json::Object(o) = b {
144 for (k, v) in o {
145 a.insert(k, v);
146 }
147 }
148 Json::Object(a)
149}
150
151fn schema(props: &[(&str, Json)], required: &[&str], repo: bool) -> Json {
152 let mut p = Map::new();
153 for (k, v) in props {
154 p.insert((*k).to_owned(), v.clone());
155 }
156 if repo {
157 p.insert("repo".to_owned(), json!({ "type": "string" }));
158 }
159 json!({ "type": "object", "properties": p, "required": required })
160}
161
162fn s() -> Json {
163 json!({ "type": "string" })
164}
165fn i() -> Json {
166 json!({ "type": "integer" })
167}
168fn b() -> Json {
169 json!({ "type": "boolean" })
170}
171fn strings() -> Json {
172 json!({ "type": "array", "items": { "type": "string" } })
173}
174fn obj() -> Json {
175 json!({ "type": "object" })
176}
177fn nullable_string() -> Json {
178 json!({ "type": ["string", "null"] })
179}
180fn resolution_schema() -> Json {
181 json!({ "type": "string", "enum": ["skeleton", "outline", "text", "raw", "full"] })
182}
183fn at_schema() -> Json {
184 json!({ "oneOf": [
185 { "const": "start" }, { "const": "end" },
186 { "type": "object", "properties": { "before": { "type": "string" } }, "required": ["before"] },
187 { "type": "object", "properties": { "after": { "type": "string" } }, "required": ["after"] }
188 ] })
189}
190fn expect_schema() -> Json {
191 json!({ "type": "object", "properties": { "content_hash": { "type": "string" }, "parent_children_hash": { "type": "string" } } })
192}
193
194#[must_use]
196pub fn tools() -> Vec<ToolSpec> {
197 let dp = |extra: &[(&str, Json)], required: &[&str]| {
198 let mut props = vec![("doc", s()), ("path", s())];
199 props.extend(extra.iter().cloned());
200 schema(&props, required, true)
201 };
202 vec![
203 ToolSpec {
204 name: "docs_outline",
205 description: "A document's compact indented outline (id, type, label per line; § marks headings). Args take a doc id or path.",
206 input_schema: dp(
207 &[
208 (
209 "resolution",
210 json!({ "type": "string", "enum": ["skeleton", "outline"] }),
211 ),
212 ("depth", i()),
213 ("budget_tokens", i()),
214 ],
215 &[],
216 ),
217 },
218 ToolSpec {
219 name: "docs_read",
220 description: "Read a whole document: verbatim `content`, `properties` grouped by source, `path`/`docId`/`rev`; include_ids adds `ids`, `hashes` (CAS tokens) and `parents`.",
221 input_schema: dp(&[("include_ids", b())], &[]),
222 },
223 ToolSpec {
224 name: "docs_get_many",
225 description: "Batch docs_read over `docs` (ids or paths): `{ items, errors, truncated }`, capped at 100 refs, optional token budget.",
226 input_schema: schema(
227 &[
228 ("docs", strings()),
229 ("include_ids", b()),
230 ("budget_tokens", i()),
231 ],
232 &["docs"],
233 true,
234 ),
235 },
236 ToolSpec {
237 name: "nodes_get",
238 description: "Hydrate one block subtree at a resolution (skeleton|outline|text|raw|full); the owning doc is inferred from `id` when `doc`/`path` are omitted.",
239 input_schema: dp(&[("id", s()), ("resolution", resolution_schema())], &["id"]),
240 },
241 ToolSpec {
242 name: "nodes_get_many",
243 description: "Fetch up to 100 blocks by id in request order with budget truncation; `doc`/`path` is an optional scope. Returns `nodes`, `truncated`, `unresolved`.",
244 input_schema: dp(
245 &[
246 ("ids", strings()),
247 ("resolution", resolution_schema()),
248 ("budget_tokens", i()),
249 ],
250 &["ids"],
251 ),
252 },
253 ToolSpec {
254 name: "read_ref",
255 description: "Read any ref — a document (id or path) or a block (`b_` id, or an `n_` node id) — classified as `{ kind: \"document\", … }` or `{ kind: \"block\", … }` (block `resolution` defaults to raw).",
256 input_schema: schema(
257 &[("ref", s()), ("resolution", resolution_schema())],
258 &["ref"],
259 true,
260 ),
261 },
262 ToolSpec {
263 name: "docs_tree",
264 description: "The directory-aware shape of a repo: live docs under `path` collapsed at `depth` segments into dir/doc entries with totals, ordered by path and paged.",
265 input_schema: schema(
266 &[
267 ("path", s()),
268 ("depth", i()),
269 ("limit", i()),
270 ("cursor", nullable_string()),
271 ("budget_tokens", i()),
272 ],
273 &[],
274 true,
275 ),
276 },
277 ToolSpec {
278 name: "docs_list",
279 description: "Enumerate live documents as a page `{ items: [{ path, blocks, ts }], truncated, cursor }`, ordered by path; `path_glob` is a LIKE match (`*` matches across `/`).",
280 input_schema: schema(
281 &[
282 ("path_glob", s()),
283 ("limit", i()),
284 ("cursor", nullable_string()),
285 ("budget_tokens", i()),
286 ],
287 &[],
288 true,
289 ),
290 },
291 ToolSpec {
292 name: "query_syntax",
293 description: "The OQX syntax reference for the `query` tool.",
294 input_schema: schema(&[], &[], false),
295 },
296 ToolSpec {
297 name: "query",
298 description: "Run one OQX query (`select … from docs|blocks|nodes|edges where … follow … order by … limit N`). Returns lean hits `{ id, path, …projections }` with `truncated` + `cursor`, or a `count`/`exists`/`none` scalar, or `values`. See query_syntax.",
299 input_schema: schema(
300 &[
301 ("query", s()),
302 ("limit", i()),
303 ("cursor", nullable_string()),
304 ],
305 &["query"],
306 true,
307 ),
308 },
309 ToolSpec {
310 name: "graph",
311 description: "The bounded neighborhood around root documents in one call — `{ documents, edges, frontier }` — compiled to an OQX `follow doc.out`/`doc.in` walk.",
312 input_schema: schema(
313 &[
314 ("roots", strings()),
315 ("degrees", i()),
316 (
317 "direction",
318 json!({ "type": "string", "enum": ["in", "out", "both"] }),
319 ),
320 ("predicate", s()),
321 ("select", strings()),
322 ("max_documents", i()),
323 ],
324 &["roots"],
325 true,
326 ),
327 },
328 ToolSpec {
329 name: "text_search",
330 description: "Full-text (FTS5, bm25-ranked) search over block text.",
331 input_schema: schema(&[("q", s()), ("limit", i())], &["q"], true),
332 },
333 ToolSpec {
334 name: "resolve",
335 description: "Resolve a name/title/phrase to the blocks it refers to: ranked `{ id, locator, preview, evidence }` (FTS, fused with the vector ranking when a provider exists).",
336 input_schema: schema(&[("query", s()), ("limit", i())], &["query"], true),
337 },
338 ToolSpec {
339 name: "apply",
340 description: "Apply a changeset of kernel ops (insert/update/move/remove/split/merge) atomically; `dry_run` previews diffs.",
341 input_schema: schema(
342 &[
343 (
344 "ops",
345 json!({ "type": "array", "items": { "type": "object" } }),
346 ),
347 ("reason", s()),
348 ("dry_run", b()),
349 ],
350 &["ops"],
351 true,
352 ),
353 },
354 ToolSpec {
355 name: "blocks_insert",
356 description: "Insert blocks parsed from `markdown` under `to` (a block ref, or a document ref for its top level) at `at` (end|start|{before|after}).",
357 input_schema: schema(
358 &[
359 ("to", s()),
360 ("markdown", s()),
361 ("at", at_schema()),
362 ("dry_run", b()),
363 ],
364 &["to", "markdown"],
365 true,
366 ),
367 },
368 ToolSpec {
369 name: "blocks_update",
370 description: "Replace a block's markdown and/or set attrs (`checked` folds into attrs) with CAS pinned server-side when `expect` is omitted. Returns `id`, `ids` and the apply result.",
371 input_schema: schema(
372 &[
373 ("block", s()),
374 ("markdown", s()),
375 ("checked", b()),
376 ("attrs", obj()),
377 ("expect", expect_schema()),
378 ("dry_run", b()),
379 ],
380 &["block"],
381 true,
382 ),
383 },
384 ToolSpec {
385 name: "blocks_move",
386 description: "Move blocks under a new parent at a position; `to` is a block ref or the blocks' own document.",
387 input_schema: schema(
388 &[
389 ("blocks", strings()),
390 ("to", s()),
391 ("at", at_schema()),
392 ("dry_run", b()),
393 ],
394 &["blocks", "to"],
395 true,
396 ),
397 },
398 ToolSpec {
399 name: "blocks_remove",
400 description: "Remove blocks (and their subtrees).",
401 input_schema: schema(
402 &[("blocks", strings()), ("dry_run", b())],
403 &["blocks"],
404 true,
405 ),
406 },
407 ToolSpec {
408 name: "blocks_split",
409 description: "Split a block at UTF-8 byte offsets; CAS pinned server-side.",
410 input_schema: schema(
411 &[
412 ("block", s()),
413 (
414 "at",
415 json!({ "type": "array", "items": { "type": "integer" } }),
416 ),
417 ("dry_run", b()),
418 ],
419 &["block", "at"],
420 true,
421 ),
422 },
423 ToolSpec {
424 name: "blocks_merge",
425 description: "Merge adjacent blocks into the first, joined by `separator`.",
426 input_schema: schema(
427 &[("blocks", strings()), ("separator", s()), ("dry_run", b())],
428 &["blocks"],
429 true,
430 ),
431 },
432 ToolSpec {
433 name: "tasks_complete",
434 description: "Check (or uncheck with checked:false) task blocks.",
435 input_schema: schema(
436 &[("blocks", strings()), ("checked", b()), ("dry_run", b())],
437 &["blocks"],
438 true,
439 ),
440 },
441 ToolSpec {
442 name: "node_set",
443 description: "Set one editable property of a projected node (a link's name/value, a task's checked).",
444 input_schema: schema(
445 &[
446 ("node", s()),
447 ("prop", s()),
448 ("value", s()),
449 ("dry_run", b()),
450 ],
451 &["node", "prop", "value"],
452 true,
453 ),
454 },
455 ToolSpec {
456 name: "sections_append",
457 description: "Append markdown at the end of a heading's section; `heading` is a heading block id or its text (scoped by `doc`/`path`).",
458 input_schema: schema(
459 &[
460 ("heading", s()),
461 ("markdown", s()),
462 ("doc", s()),
463 ("path", s()),
464 ("dry_run", b()),
465 ],
466 &["heading", "markdown"],
467 true,
468 ),
469 },
470 ToolSpec {
471 name: "docs_append",
472 description: "Append markdown at the end of a document as new top-level blocks (existing ids preserved).",
473 input_schema: schema(
474 &[("doc", s()), ("path", s()), ("text", s())],
475 &["text"],
476 true,
477 ),
478 },
479 ToolSpec {
480 name: "links_retarget",
481 description: "Rewrite one link destination everywhere it is linked (dry run by default).",
482 input_schema: schema(
483 &[
484 ("from_target", s()),
485 ("to_target", s()),
486 ("path_glob", s()),
487 ("dry_run", b()),
488 ],
489 &["from_target", "to_target"],
490 true,
491 ),
492 },
493 ToolSpec {
494 name: "links_stale",
495 description: "Dangling internal links (open edges to a `phantom:` target) plus external and total counts; `summary:true` returns counts only.",
496 input_schema: schema(
497 &[("path_glob", s()), ("limit", i()), ("summary", b())],
498 &[],
499 true,
500 ),
501 },
502 ToolSpec {
503 name: "links_repair",
504 description: "Bulk link repair: `repairs` ([{from,to}]) or one `from_target`/`to_target` pair, in one changeset (dry run by default).",
505 input_schema: schema(
506 &[
507 (
508 "repairs",
509 json!({ "type": "array", "items": { "type": "object", "properties": { "from": { "type": "string" }, "to": { "type": "string" } }, "required": ["from", "to"] } }),
510 ),
511 ("from_target", s()),
512 ("to_target", s()),
513 ("path_glob", s()),
514 ("dry_run", b()),
515 ],
516 &[],
517 true,
518 ),
519 },
520 ToolSpec {
521 name: "docs_create",
522 description: "Create a document at `path` from `markdown` with optional `frontmatter`.",
523 input_schema: schema(
524 &[("path", s()), ("markdown", s()), ("frontmatter", obj())],
525 &["path", "markdown"],
526 true,
527 ),
528 },
529 ToolSpec {
530 name: "docs_move",
531 description: "Rename a document to `to_path`, identity preserved; `retarget_inbound` rewrites inbound links.",
532 input_schema: schema(
533 &[("doc", s()), ("to_path", s()), ("retarget_inbound", b())],
534 &["doc", "to_path"],
535 true,
536 ),
537 },
538 ToolSpec {
539 name: "docs_delete",
540 description: "Delete a document: tombstone it and remove the file.",
541 input_schema: schema(&[("doc", s())], &["doc"], true),
542 },
543 ToolSpec {
544 name: "docs_set_meta",
545 description: "Set and/or unset frontmatter keys, re-ingesting the document.",
546 input_schema: schema(
547 &[("doc", s()), ("set", obj()), ("unset", strings())],
548 &["doc"],
549 true,
550 ),
551 },
552 ToolSpec {
553 name: "docs_plan_update",
554 description: "Plan a whole-document update without applying: the opset and a one-line-per-op plan.",
555 input_schema: schema(&[("doc", s()), ("content", s())], &["doc", "content"], true),
556 },
557 ToolSpec {
558 name: "docs_update",
559 description: "Whole-document update with identity preservation: plan then apply (`dry_run` returns the plan only).",
560 input_schema: schema(
561 &[
562 ("doc", s()),
563 ("content", s()),
564 ("reason", s()),
565 ("dry_run", b()),
566 ],
567 &["doc", "content"],
568 true,
569 ),
570 },
571 ToolSpec {
572 name: "observe",
573 description: "Record `content` as the authoritative bytes at `path` (an observed-origin commit; an echo when unchanged).",
574 input_schema: schema(
575 &[("path", s()), ("content", s())],
576 &["path", "content"],
577 true,
578 ),
579 },
580 ToolSpec {
581 name: "observe_many",
582 description: "Observe several files under one timestamp and one pool sweep.",
583 input_schema: schema(
584 &[(
585 "files",
586 json!({ "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "content": { "type": "string" } }, "required": ["path", "content"] } }),
587 )],
588 &["files"],
589 true,
590 ),
591 },
592 ToolSpec {
593 name: "observe_delete",
594 description: "Record that `path` left the source: an observed, pooled tombstone.",
595 input_schema: schema(&[("path", s())], &["path"], true),
596 },
597 ToolSpec {
598 name: "history_node",
599 description: "A block's biography: the commits that touched it, newest first.",
600 input_schema: schema(&[("id", s()), ("limit", i())], &["id"], false),
601 },
602 ToolSpec {
603 name: "diff",
604 description: "Block-grain diff between two revisions of a document.",
605 input_schema: schema(
606 &[("doc", s()), ("from_rev", s()), ("to_rev", s())],
607 &["doc", "from_rev", "to_rev"],
608 true,
609 ),
610 },
611 ToolSpec {
612 name: "diff_unified",
613 description: "Unified diff (Myers, 3 lines of context) between two revisions (default: the previous and current).",
614 input_schema: schema(
615 &[("doc", s()), ("from_rev", s()), ("to_rev", s())],
616 &["doc"],
617 true,
618 ),
619 },
620 ToolSpec {
621 name: "docs_read_at",
622 description: "The whole document as of a past revision.",
623 input_schema: dp(&[("rev", s())], &["rev"]),
624 },
625 ToolSpec {
626 name: "docs_history",
627 description: "Version history of the docs matching `path_glob` or `doc`, grouped by document.",
628 input_schema: schema(
629 &[
630 ("path_glob", s()),
631 ("doc", s()),
632 ("include_deleted", b()),
633 ("limit", i()),
634 ],
635 &[],
636 true,
637 ),
638 },
639 ToolSpec {
640 name: "changes_since",
641 description: "The change feed: commit digests after `cursor` (a repo commit seq).",
642 input_schema: schema(
643 &[
644 ("cursor", i()),
645 (
646 "origin",
647 json!({ "type": "string", "enum": ["api", "observed", "import"] }),
648 ),
649 ("limit", i()),
650 ],
651 &[],
652 true,
653 ),
654 },
655 ToolSpec {
656 name: "repos_status",
657 description: "Repo counts, unconverged docs and on-disk drift.",
658 input_schema: schema(&[], &[], true),
659 },
660 ToolSpec {
661 name: "sync_status",
662 description: "Sync state: last commit seq, last checkpoint, convergence.",
663 input_schema: schema(&[], &[], true),
664 },
665 ToolSpec {
666 name: "repos",
667 description: "The repos in this workspace: `{ repos: [{ slug, hasSource }] }`.",
668 input_schema: schema(&[], &[], false),
669 },
670 ]
671}
672
673impl Surface {
674 #[must_use]
677 pub fn new(
678 store: Store,
679 default_repo: &str,
680 provider: Option<Box<dyn EmbeddingProvider>>,
681 ) -> Self {
682 Self {
683 store,
684 default_repo: default_repo.to_owned(),
685 provider,
686 on_mutation: None,
687 clock: Box::new(omgbase_sync::now_ts),
688 writes: WriteTarget::Derived,
689 config: Config::default(),
690 }
691 }
692
693 #[must_use]
696 pub fn with_doc_store(mut self, doc_store: Box<dyn DocStore>) -> Self {
697 self.writes = WriteTarget::Fixed(doc_store);
698 self
699 }
700
701 #[must_use]
703 pub fn with_clock(mut self, clock: impl FnMut() -> String + 'static) -> Self {
704 self.clock = Box::new(clock);
705 self
706 }
707
708 #[must_use]
710 pub fn with_mutation_hook(mut self, hook: impl FnMut() + 'static) -> Self {
711 self.on_mutation = Some(Box::new(hook));
712 self
713 }
714
715 #[must_use]
717 pub fn with_config(mut self, config: Config) -> Self {
718 self.config = config;
719 self
720 }
721
722 #[must_use]
723 pub fn store(&self) -> &Store {
724 &self.store
725 }
726
727 pub fn store_mut(&mut self) -> &mut Store {
728 &mut self.store
729 }
730
731 #[must_use]
732 pub fn default_repo(&self) -> &str {
733 &self.default_repo
734 }
735
736 #[must_use]
738 pub fn tools(&self) -> Vec<ToolSpec> {
739 tools()
740 }
741
742 #[must_use]
746 pub fn is_write_tool(name: &str) -> bool {
747 matches!(
748 name,
749 "apply"
750 | "blocks_insert"
751 | "blocks_update"
752 | "blocks_move"
753 | "blocks_remove"
754 | "blocks_split"
755 | "blocks_merge"
756 | "tasks_complete"
757 | "node_set"
758 | "sections_append"
759 | "docs_append"
760 | "links_retarget"
761 | "links_repair"
762 | "docs_create"
763 | "docs_move"
764 | "docs_delete"
765 | "docs_set_meta"
766 | "docs_update"
767 | "observe"
768 | "observe_many"
769 | "observe_delete"
770 )
771 }
772
773 pub fn call(&mut self, name: &str, args: Json) -> ToolOutcome {
775 match self.call_result(name, &args) {
776 Ok(body) => ToolOutcome {
777 body,
778 is_error: false,
779 },
780 Err(e) => ToolOutcome {
781 body: e.to_json(),
782 is_error: true,
783 },
784 }
785 }
786
787 fn now(&mut self) -> String {
788 (self.clock)()
789 }
790
791 fn notify(&mut self) {
792 if let Some(hook) = &mut self.on_mutation {
793 hook();
794 }
795 }
796
797 fn repo_rows(&self) -> Result<Vec<RepoRow>> {
800 Ok(omgbase_sync::workspace::list_repos(&self.store)?)
801 }
802
803 fn scope(&self, args: &Json) -> Result<(String, Option<String>)> {
805 let rows = self.repo_rows()?;
806 match arg_str(args, "repo") {
807 None => {
808 let root = rows
809 .iter()
810 .find(|r| r.repo_id == self.default_repo)
811 .and_then(|r| r.root_path.clone());
812 Ok((self.default_repo.clone(), root))
813 }
814 Some(slug) => rows
815 .iter()
816 .find(|r| r.slug == slug)
817 .map(|r| (r.repo_id.clone(), r.root_path.clone()))
818 .ok_or_else(|| {
819 SurfaceError::with_data(
820 "repo_not_found",
821 format!("no repo '{slug}' in this workspace"),
822 json!({ "repo": slug }),
823 )
824 }),
825 }
826 }
827
828 fn require_root(&self, root: Option<&str>) -> Result<()> {
829 if matches!(self.writes, WriteTarget::Fixed(_)) || root.is_some() {
830 Ok(())
831 } else {
832 Err(SurfaceError::new(
833 "repo_not_found",
834 "repo has no filesystem source; mutation disabled",
835 ))
836 }
837 }
838
839 fn with_writes<T>(
841 &mut self,
842 root: Option<&str>,
843 f: impl FnOnce(&mut Store, &mut dyn DocStore) -> Result<T>,
844 ) -> Result<T> {
845 self.require_root(root)?;
846 match &mut self.writes {
847 WriteTarget::Fixed(ds) => f(&mut self.store, ds.as_mut()),
848 WriteTarget::Derived => {
849 let mut fs = FsDocStore::new(root.expect("checked by require_root"));
850 f(&mut self.store, &mut fs)
851 }
852 }
853 }
854
855 fn resolve_doc_id(
859 &self,
860 repo_id: &str,
861 doc: Option<&str>,
862 path: Option<&str>,
863 block: Option<&str>,
864 ) -> Result<String> {
865 let conn = self.store.conn();
866 let found = if let Some(d) = doc.filter(|d| !d.is_empty()) {
867 read::find_doc_by_ref(conn, repo_id, d)?.map(|i| i.doc_id)
868 } else if let Some(p) = path.filter(|p| !p.is_empty()) {
869 read::find_doc_by_path(conn, repo_id, p)?.map(|i| i.doc_id)
870 } else if let Some(b) = block.filter(|b| !b.is_empty()) {
871 conn.query_row(
872 "SELECT doc_id FROM blocks WHERE block_id = ?1",
873 params![b],
874 |r| r.get::<_, String>(0),
875 )
876 .optional()?
877 } else {
878 None
879 };
880 found.ok_or_else(|| {
881 let mut m = Map::new();
882 if let Some(d) = doc {
883 m.insert("doc".to_owned(), json!(d));
884 }
885 if let Some(p) = path {
886 m.insert("path".to_owned(), json!(p));
887 }
888 if let Some(b) = block {
889 m.insert("block".to_owned(), json!(b));
890 }
891 SurfaceError::with_data(
892 "doc_missing",
893 format!("no document for {}", Json::Object(m.clone())),
894 Json::Object(m),
895 )
896 })
897 }
898
899 fn resolve_doc_from_args(&self, repo_id: &str, args: &Json) -> Result<String> {
900 self.resolve_doc_id(repo_id, arg_str(args, "doc"), arg_str(args, "path"), None)
901 }
902
903 fn resolve_heading_id(
905 &self,
906 repo_id: &str,
907 heading: &str,
908 doc: Option<&str>,
909 path: Option<&str>,
910 ) -> Result<String> {
911 let conn = self.store.conn();
912 let as_block: Option<String> = conn
913 .query_row(
914 "SELECT block_id FROM blocks WHERE block_id = ?1 AND type = 'heading' AND deleted_commit IS NULL",
915 params![heading],
916 |r| r.get(0),
917 )
918 .optional()?;
919 if let Some(b) = as_block {
920 return Ok(b);
921 }
922 let want_doc = if doc.is_some_and(|d| !d.is_empty()) || path.is_some_and(|p| !p.is_empty())
923 {
924 Some(self.resolve_doc_id(repo_id, doc, path, None)?)
925 } else {
926 None
927 };
928 let needle = if heading.trim_start_matches([' ', '\t']).starts_with('#') {
929 normalize_visible_text(heading, BlockKind::Heading, 0)
930 } else {
931 normalize_text(heading)
932 };
933 let rows: Vec<(String, String, String)> = match &want_doc {
934 Some(d) => {
935 let mut stmt = conn.prepare(
936 "SELECT block_id, doc_id, text FROM blocks WHERE repo_id = ?1 AND type = 'heading' AND doc_id = ?2 AND deleted_commit IS NULL",
937 )?;
938 let it = stmt.query_map(params![repo_id, d], |r| {
939 Ok((r.get(0)?, r.get(1)?, r.get(2)?))
940 })?;
941 it.collect::<std::result::Result<_, _>>()?
942 }
943 None => {
944 let mut stmt = conn.prepare(
945 "SELECT block_id, doc_id, text FROM blocks WHERE repo_id = ?1 AND type = 'heading' AND deleted_commit IS NULL",
946 )?;
947 let it =
948 stmt.query_map(params![repo_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
949 it.collect::<std::result::Result<_, _>>()?
950 }
951 };
952 let matches: Vec<&(String, String, String)> = rows
953 .iter()
954 .filter(|(_, _, text)| normalize_text(text) == needle)
955 .collect();
956 match matches.len() {
957 0 => {
958 let mut data = Map::new();
959 data.insert("heading".to_owned(), json!(heading));
960 if let Some(d) = want_doc {
961 data.insert("doc".to_owned(), json!(d));
962 }
963 Err(SurfaceError::with_data(
964 "parent_missing",
965 format!("no heading matching {}", Json::String(heading.to_owned())),
966 Json::Object(data),
967 ))
968 }
969 1 => Ok(matches[0].0.clone()),
970 n => Err(SurfaceError::with_data(
971 "ambiguous_heading",
972 format!(
973 "heading {} matches {n} headings; pass its block id or a doc/path scope",
974 Json::String(heading.to_owned())
975 ),
976 json!({
977 "heading": heading,
978 "candidates": matches.iter().map(|(b, d, _)| json!({ "block": b, "doc": d })).collect::<Vec<_>>(),
979 }),
980 )),
981 }
982 }
983
984 fn resolve_block_ref(&self, repo_id: &str, r: &str) -> Result<String> {
986 match read::resolve_ref(self.store.conn(), repo_id, r)? {
987 Some(ResolvedRef::Block { block_id, .. }) => Ok(block_id),
988 _ => Err(SurfaceError::with_data(
989 "block_missing",
990 format!("not a block: {r}"),
991 json!({ "ref": r }),
992 )),
993 }
994 }
995
996 fn resolve_parent_ref(&self, repo_id: &str, r: &str) -> Result<(Parent, String)> {
998 match read::resolve_ref(self.store.conn(), repo_id, r)? {
999 Some(ResolvedRef::Block { doc_id, block_id }) => Ok((Parent::Block(block_id), doc_id)),
1000 Some(ResolvedRef::Document { doc_id }) => Ok((Parent::Doc, doc_id)),
1001 None => Err(SurfaceError::with_data(
1002 "block_missing",
1003 format!("not a block or document: {r}"),
1004 json!({ "ref": r }),
1005 )),
1006 }
1007 }
1008
1009 fn doc_id_of_block(&self, block_id: &str) -> Result<Option<String>> {
1010 Ok(self
1011 .store
1012 .conn()
1013 .query_row(
1014 "SELECT doc_id FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
1015 params![block_id],
1016 |r| r.get(0),
1017 )
1018 .optional()?)
1019 }
1020
1021 fn pin_hash(&self, block_id: &str) -> Result<Option<String>> {
1023 let h: Option<Vec<u8>> = self
1024 .store
1025 .conn()
1026 .query_row(
1027 "SELECT raw_hash FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
1028 params![block_id],
1029 |r| r.get(0),
1030 )
1031 .optional()?;
1032 Ok(h.map(|h| omgbase_format::hash::hex(&h)))
1033 }
1034
1035 fn resolve_at(&self, repo_id: &str, at: Option<&Json>) -> Result<At> {
1037 match at {
1038 None | Some(Json::Null) => Ok(At::End),
1039 Some(Json::String(s)) if s == "start" => Ok(At::Start),
1040 Some(Json::String(s)) if s == "end" => Ok(At::End),
1041 Some(Json::Object(o)) => {
1042 if let Some(b) = o.get("before").and_then(Json::as_str) {
1043 return Ok(At::Before(self.resolve_block_ref(repo_id, b)?));
1044 }
1045 if let Some(a) = o.get("after").and_then(Json::as_str) {
1046 return Ok(At::After(self.resolve_block_ref(repo_id, a)?));
1047 }
1048 Err(bad_args(
1049 "`at` must be \"start\", \"end\", {before} or {after}",
1050 ))
1051 }
1052 Some(_) => Err(bad_args(
1053 "`at` must be \"start\", \"end\", {before} or {after}",
1054 )),
1055 }
1056 }
1057
1058 fn apply_ops(
1061 &mut self,
1062 repo_id: &str,
1063 root: Option<&str>,
1064 ops: Vec<Op>,
1065 reason: &str,
1066 dry_run: bool,
1067 ) -> Result<ApplyResult> {
1068 let ts = self.now();
1069 let req = ApplyRequest {
1070 repo_id: repo_id.to_owned(),
1071 ops,
1072 origin: ApplyOrigin::new(ACTOR, Some(reason)),
1073 dry_run,
1074 set_frontmatter: Vec::new(),
1075 };
1076 let res = self.with_writes(root, |store, ds| Ok(store.apply(&req, ds, &ts)?))?;
1077 if !dry_run {
1078 self.notify();
1079 }
1080 Ok(res)
1081 }
1082
1083 fn doc_ctx(&mut self, repo_id: &str) -> DocOpContext {
1084 DocOpContext {
1085 repo_id: repo_id.to_owned(),
1086 actor: Some(ACTOR.to_owned()),
1087 ts: self.now(),
1088 }
1089 }
1090
1091 #[allow(clippy::too_many_lines)]
1095 pub fn call_result(&mut self, name: &str, args: &Json) -> Result<Json> {
1096 match name {
1097 "docs_outline" => {
1098 let (repo, _) = self.scope(args)?;
1099 let doc_id = self.resolve_doc_from_args(&repo, args)?;
1100 let skeleton = match arg_str(args, "resolution") {
1101 None | Some("outline") => false,
1102 Some("skeleton") => true,
1103 Some(_) => return Err(bad_args("`resolution` must be skeleton or outline")),
1104 };
1105 read::docs_outline(
1106 &self.store,
1107 &doc_id,
1108 skeleton,
1109 arg_i64(args, "depth")?,
1110 arg_usize(args, "budget_tokens")?,
1111 )
1112 }
1113 "docs_read" => {
1114 let (repo, _) = self.scope(args)?;
1115 let doc_id = self.resolve_doc_from_args(&repo, args)?;
1116 read::docs_read(
1117 &self.store,
1118 &doc_id,
1119 arg_bool(args, "include_ids")?.unwrap_or(false),
1120 )?
1121 .ok_or_else(|| SurfaceError::new("doc_missing", format!("no document for {args}")))
1122 }
1123 "docs_get_many" => {
1124 let (repo, _) = self.scope(args)?;
1125 let refs = arg_strings(args, "docs")?;
1126 read::docs_read_many(
1127 &self.store,
1128 &repo,
1129 &refs,
1130 arg_bool(args, "include_ids")?.unwrap_or(false),
1131 arg_usize(args, "budget_tokens")?,
1132 )
1133 }
1134 "nodes_get" => {
1135 let (repo, _) = self.scope(args)?;
1136 let id = arg_string(args, "id")?;
1137 let doc_id = self.resolve_doc_id(
1138 &repo,
1139 arg_str(args, "doc"),
1140 arg_str(args, "path"),
1141 Some(&id),
1142 )?;
1143 read::nodes_get(
1144 &self.store,
1145 &doc_id,
1146 &id,
1147 resolution_arg(args, Resolution::Full)?,
1148 )?
1149 .ok_or_else(|| SurfaceError::new("block_missing", format!("no block {id}")))
1150 }
1151 "nodes_get_many" => {
1152 let (repo, _) = self.scope(args)?;
1153 let ids = arg_strings(args, "ids")?;
1154 let scoped = arg_str(args, "doc").is_some_and(|d| !d.is_empty())
1155 || arg_str(args, "path").is_some_and(|p| !p.is_empty());
1156 let doc_id = if scoped {
1157 Some(self.resolve_doc_from_args(&repo, args)?)
1158 } else {
1159 None
1160 };
1161 read::nodes_get_many(
1162 &self.store,
1163 doc_id.as_deref(),
1164 &ids,
1165 resolution_arg(args, Resolution::Text)?,
1166 arg_usize(args, "budget_tokens")?,
1167 )
1168 }
1169 "read_ref" => {
1170 let (repo, _) = self.scope(args)?;
1171 let r = arg_string(args, "ref")?;
1172 let resolved =
1173 read::resolve_ref(self.store.conn(), &repo, &r)?.ok_or_else(|| {
1174 SurfaceError::new(
1175 "doc_missing",
1176 format!("no document or block for {}", Json::String(r.clone())),
1177 )
1178 })?;
1179 match resolved {
1180 ResolvedRef::Document { doc_id } => {
1181 let res =
1182 read::docs_read(&self.store, &doc_id, false)?.ok_or_else(|| {
1183 SurfaceError::new(
1184 "doc_missing",
1185 format!("no document for {}", Json::String(r.clone())),
1186 )
1187 })?;
1188 let mut m = Map::new();
1189 m.insert("kind".to_owned(), json!("document"));
1190 Ok(merge(m, res))
1191 }
1192 ResolvedRef::Block { doc_id, block_id } => {
1193 let node = read::nodes_get(
1194 &self.store,
1195 &doc_id,
1196 &block_id,
1197 resolution_arg(args, Resolution::Raw)?,
1198 )?
1199 .ok_or_else(|| {
1200 SurfaceError::new("block_missing", format!("no block {block_id}"))
1201 })?;
1202 let mut m = Map::new();
1203 m.insert("kind".to_owned(), json!("block"));
1204 Ok(merge(m, node))
1205 }
1206 }
1207 }
1208 "docs_tree" => {
1209 let (repo, _) = self.scope(args)?;
1210 read::docs_tree(
1211 &self.store,
1212 &repo,
1213 arg_str(args, "path"),
1214 arg_i64(args, "depth")?,
1215 arg_i64(args, "limit")?,
1216 arg_str(args, "cursor"),
1217 arg_usize(args, "budget_tokens")?,
1218 )
1219 }
1220 "docs_list" => {
1221 let (repo, _) = self.scope(args)?;
1222 read::docs_list(
1223 &self.store,
1224 &repo,
1225 arg_str(args, "path_glob"),
1226 arg_i64(args, "limit")?,
1227 arg_str(args, "cursor"),
1228 arg_usize(args, "budget_tokens")?,
1229 )
1230 }
1231 "query_syntax" => Ok(json!({ "syntax": QUERY_SYNTAX })),
1232 "query" => {
1233 let (repo, _) = self.scope(args)?;
1234 let source = arg_string(args, "query")?;
1235 if self.provider.is_none()
1238 && !crate::query::collect_semantic_phrases(&source).is_empty()
1239 {
1240 return Err(SurfaceError::new(
1241 "semantic_unavailable",
1242 "no embedding provider configured for this server",
1243 ));
1244 }
1245 let opts = QueryOptions {
1246 limit: arg_usize(args, "limit")?,
1247 cursor: arg_str(args, "cursor"),
1248 provider: self.provider.as_deref(),
1249 in_memory: false,
1250 };
1251 Ok(query(&self.store, &repo, &source, opts)?.to_json())
1252 }
1253 "graph" => {
1254 let (repo, _) = self.scope(args)?;
1255 let g = GraphArgs {
1256 roots: arg_strings(args, "roots")?,
1257 degrees: arg_i64(args, "degrees")?,
1258 direction: arg_str(args, "direction").map(str::to_owned),
1259 predicate: arg_str(args, "predicate").map(str::to_owned),
1260 select: if args.get("select").is_some() {
1261 arg_strings(args, "select")?
1262 } else {
1263 Vec::new()
1264 },
1265 max_documents: arg_i64(args, "max_documents")?,
1266 };
1267 if self.provider.is_none()
1268 && g.select.iter().any(|s| {
1269 !crate::query::collect_semantic_phrases(&format!("from docs select x: {s}"))
1270 .is_empty()
1271 })
1272 {
1273 return Err(SurfaceError::new(
1274 "semantic_unavailable",
1275 "no embedding provider configured for this server",
1276 ));
1277 }
1278 graph_neighborhood(&self.store, &repo, &g, self.provider.as_deref())
1279 }
1280 "text_search" => {
1281 let (repo, _) = self.scope(args)?;
1282 let q = arg_string(args, "q")?;
1283 let res =
1284 self.store
1285 .text_search(&repo, &q, arg_usize(args, "limit")?.unwrap_or(50))?;
1286 Ok(json!({
1287 "hits": res.hits.iter().map(|h| json!({
1288 "blockId": h.block_id, "docId": h.doc_id, "path": h.path, "type": h.block_type, "text": h.text, "score": h.score,
1289 })).collect::<Vec<_>>(),
1290 "truncated": res.truncated,
1291 }))
1292 }
1293 "resolve" => {
1294 let (repo, _) = self.scope(args)?;
1295 let q = arg_string(args, "query")?;
1296 let vector = match &self.provider {
1297 Some(p) => Some(QueryVector {
1298 model: p.model().to_owned(),
1299 vec: p
1300 .embed_query(&q)
1301 .map_err(|e| SurfaceError::new(e.code(), e.to_string()))?,
1302 }),
1303 None => None,
1304 };
1305 let hits = self
1306 .store
1307 .resolve(&repo, &q, vector, arg_usize(args, "limit")?)?;
1308 Ok(Json::Array(hits.iter().map(|h| json!({
1309 "id": h.id, "locator": h.locator, "preview": h.preview, "evidence": evidence_json(&h.evidence),
1310 })).collect()))
1311 }
1312 "apply" => {
1313 let (repo, root) = self.scope(args)?;
1314 self.require_root(root.as_deref())?;
1315 let ops = args
1316 .get("ops")
1317 .and_then(Json::as_array)
1318 .ok_or_else(|| bad_args("`ops` must be an array"))?;
1319 let ops: Vec<Op> = ops
1320 .iter()
1321 .map(|o| Op::from_json(o).map_err(bad_args))
1322 .collect::<Result<_>>()?;
1323 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1324 let reason = arg_str(args, "reason").map(str::to_owned);
1325 let ts = self.now();
1326 let req = ApplyRequest {
1327 repo_id: repo.clone(),
1328 ops,
1329 origin: ApplyOrigin::new(ACTOR, reason.as_deref()),
1330 dry_run: dry,
1331 set_frontmatter: Vec::new(),
1332 };
1333 let res =
1334 self.with_writes(root.as_deref(), |store, ds| Ok(store.apply(&req, ds, &ts)?))?;
1335 if !dry {
1336 self.notify();
1337 }
1338 Ok(apply_json(&res))
1339 }
1340 "blocks_insert" => {
1341 let (repo, root) = self.scope(args)?;
1342 self.require_root(root.as_deref())?;
1343 let (parent, doc_id) = self.resolve_parent_ref(&repo, &arg_string(args, "to")?)?;
1344 let at = self.resolve_at(&repo, args.get("at"))?;
1345 let doc = if parent == Parent::Doc {
1346 Some(doc_id)
1347 } else {
1348 None
1349 };
1350 let ops = vec![Op::Insert {
1351 doc,
1352 to: To { parent, at },
1353 markdown: arg_string(args, "markdown")?,
1354 expect: None,
1355 }];
1356 Ok(apply_json(&self.apply_ops(
1357 &repo,
1358 root.as_deref(),
1359 ops,
1360 "blocks_insert",
1361 arg_bool(args, "dry_run")?.unwrap_or(false),
1362 )?))
1363 }
1364 "blocks_update" => {
1365 let (repo, root) = self.scope(args)?;
1366 self.require_root(root.as_deref())?;
1367 let block = self.resolve_block_ref(&repo, &arg_string(args, "block")?)?;
1368 let expect = match arg_object(args, "expect")? {
1369 Some(e) => Some(Expect {
1370 content_hash: e
1371 .get("content_hash")
1372 .and_then(Json::as_str)
1373 .map(str::to_owned),
1374 parent_children_hash: e
1375 .get("parent_children_hash")
1376 .and_then(Json::as_str)
1377 .map(str::to_owned),
1378 }),
1379 None => self.pin_hash(&block)?.map(Expect::content),
1380 };
1381 let checked = arg_bool(args, "checked")?;
1382 let extra = arg_object(args, "attrs")?;
1383 let attrs = if checked.is_some() || extra.is_some() {
1384 let mut a = Map::new();
1385 if let Some(c) = checked {
1386 a.insert("checked".to_owned(), json!(c));
1387 }
1388 if let Some(x) = extra {
1389 for (k, v) in x {
1390 a.insert(k.clone(), v.clone());
1391 }
1392 }
1393 Some(a)
1394 } else {
1395 None
1396 };
1397 let ops = vec![Op::Update {
1398 block: block.clone(),
1399 markdown: arg_str(args, "markdown").map(str::to_owned),
1400 attrs,
1401 expect,
1402 trivia: None,
1403 child_ids: None,
1404 }];
1405 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1406 let res = self.apply_ops(&repo, root.as_deref(), ops, "blocks_update", dry)?;
1407 let ids: Vec<String> = res
1408 .results
1409 .first()
1410 .map_or_else(|| vec![block.clone()], |r| r.ids.clone());
1411 let mut m = Map::new();
1412 m.insert(
1413 "id".to_owned(),
1414 json!(ids.first().cloned().unwrap_or(block)),
1415 );
1416 m.insert("ids".to_owned(), json!(ids));
1417 Ok(merge(m, apply_json(&res)))
1418 }
1419 "blocks_move" => {
1420 let (repo, root) = self.scope(args)?;
1421 self.require_root(root.as_deref())?;
1422 let blocks: Vec<String> = arg_strings(args, "blocks")?
1423 .iter()
1424 .map(|b| self.resolve_block_ref(&repo, b))
1425 .collect::<Result<_>>()?;
1426 let to_ref = arg_string(args, "to")?;
1427 let (parent, doc_id) = self.resolve_parent_ref(&repo, &to_ref)?;
1428 if parent == Parent::Doc {
1429 if let Some(first) = blocks.first() {
1430 if self.doc_id_of_block(first)? != Some(doc_id) {
1431 return Err(SurfaceError::with_data(
1432 "target_missing",
1433 format!(
1434 "blocks_move cannot target another document's root ({to_ref}); anchor on a block in that document with at.before/at.after"
1435 ),
1436 json!({ "to": to_ref }),
1437 ));
1438 }
1439 }
1440 }
1441 let at = self.resolve_at(&repo, args.get("at"))?;
1442 let ops = vec![Op::Move {
1443 blocks,
1444 to: To { parent, at },
1445 expect: None,
1446 }];
1447 Ok(apply_json(&self.apply_ops(
1448 &repo,
1449 root.as_deref(),
1450 ops,
1451 "blocks_move",
1452 arg_bool(args, "dry_run")?.unwrap_or(false),
1453 )?))
1454 }
1455 "blocks_remove" => {
1456 let (repo, root) = self.scope(args)?;
1457 self.require_root(root.as_deref())?;
1458 let blocks: Vec<String> = arg_strings(args, "blocks")?
1459 .iter()
1460 .map(|b| self.resolve_block_ref(&repo, b))
1461 .collect::<Result<_>>()?;
1462 let ops = vec![Op::Remove {
1463 blocks,
1464 expect: None,
1465 }];
1466 Ok(apply_json(&self.apply_ops(
1467 &repo,
1468 root.as_deref(),
1469 ops,
1470 "blocks_remove",
1471 arg_bool(args, "dry_run")?.unwrap_or(false),
1472 )?))
1473 }
1474 "blocks_split" => {
1475 let (repo, root) = self.scope(args)?;
1476 self.require_root(root.as_deref())?;
1477 let block = self.resolve_block_ref(&repo, &arg_string(args, "block")?)?;
1478 let at: Vec<usize> = args
1479 .get("at")
1480 .and_then(Json::as_array)
1481 .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))?
1482 .iter()
1483 .map(|v| {
1484 v.as_u64()
1485 .map(|n| usize::try_from(n).unwrap_or(usize::MAX))
1486 .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))
1487 })
1488 .collect::<Result<_>>()?;
1489 let expect = Some(Expect::content(self.pin_hash(&block)?.unwrap_or_default()));
1491 let ops = vec![Op::Split { block, at, expect }];
1492 Ok(apply_json(&self.apply_ops(
1493 &repo,
1494 root.as_deref(),
1495 ops,
1496 "blocks_split",
1497 arg_bool(args, "dry_run")?.unwrap_or(false),
1498 )?))
1499 }
1500 "blocks_merge" => {
1501 let (repo, root) = self.scope(args)?;
1502 self.require_root(root.as_deref())?;
1503 let blocks: Vec<String> = arg_strings(args, "blocks")?
1504 .iter()
1505 .map(|b| self.resolve_block_ref(&repo, b))
1506 .collect::<Result<_>>()?;
1507 let ops = vec![Op::Merge {
1508 blocks,
1509 separator: arg_str(args, "separator").map(str::to_owned),
1510 expect: None,
1511 }];
1512 Ok(apply_json(&self.apply_ops(
1513 &repo,
1514 root.as_deref(),
1515 ops,
1516 "blocks_merge",
1517 arg_bool(args, "dry_run")?.unwrap_or(false),
1518 )?))
1519 }
1520 "tasks_complete" => {
1521 let (repo, root) = self.scope(args)?;
1522 self.require_root(root.as_deref())?;
1523 let blocks: Vec<String> = arg_strings(args, "blocks")?
1524 .iter()
1525 .map(|b| self.resolve_block_ref(&repo, b))
1526 .collect::<Result<_>>()?;
1527 let ops = if arg_bool(args, "checked")?.unwrap_or(true) {
1528 self.store.tasks_complete(&blocks)?
1529 } else {
1530 let mut ops = Vec::with_capacity(blocks.len());
1531 for b in &blocks {
1532 let mut a = Map::new();
1533 a.insert("checked".to_owned(), json!(false));
1534 ops.push(Op::Update {
1535 block: b.clone(),
1536 markdown: None,
1537 attrs: Some(a),
1538 expect: self.pin_hash(b)?.map(Expect::content),
1539 trivia: None,
1540 child_ids: None,
1541 });
1542 }
1543 ops
1544 };
1545 Ok(apply_json(&self.apply_ops(
1546 &repo,
1547 root.as_deref(),
1548 ops,
1549 "tasks_complete",
1550 arg_bool(args, "dry_run")?.unwrap_or(false),
1551 )?))
1552 }
1553 "node_set" => {
1554 let (repo, root) = self.scope(args)?;
1555 self.require_root(root.as_deref())?;
1556 let ops = self.store.node_set(
1557 &arg_string(args, "node")?,
1558 &arg_string(args, "prop")?,
1559 &arg_string(args, "value")?,
1560 )?;
1561 Ok(apply_json(&self.apply_ops(
1562 &repo,
1563 root.as_deref(),
1564 ops,
1565 "node_set",
1566 arg_bool(args, "dry_run")?.unwrap_or(false),
1567 )?))
1568 }
1569 "sections_append" => {
1570 let (repo, root) = self.scope(args)?;
1571 self.require_root(root.as_deref())?;
1572 let heading = self.resolve_heading_id(
1573 &repo,
1574 &arg_string(args, "heading")?,
1575 arg_str(args, "doc"),
1576 arg_str(args, "path"),
1577 )?;
1578 let ops = Store::sections_append(&heading, &arg_string(args, "markdown")?);
1579 Ok(apply_json(&self.apply_ops(
1580 &repo,
1581 root.as_deref(),
1582 ops,
1583 "sections_append",
1584 arg_bool(args, "dry_run")?.unwrap_or(false),
1585 )?))
1586 }
1587 "docs_append" => {
1588 let (repo, root) = self.scope(args)?;
1589 self.require_root(root.as_deref())?;
1590 let doc_id = self.resolve_doc_from_args(&repo, args)?;
1591 let ops = Store::docs_append(&doc_id, &arg_string(args, "text")?);
1592 Ok(apply_json(&self.apply_ops(
1593 &repo,
1594 root.as_deref(),
1595 ops,
1596 "docs_append",
1597 false,
1598 )?))
1599 }
1600 "links_retarget" | "links_repair" => {
1601 let (repo, root) = self.scope(args)?;
1602 self.require_root(root.as_deref())?;
1603 let repairs: Vec<omgbase_store::LinkRepair> = if name == "links_retarget" {
1604 vec![omgbase_store::LinkRepair {
1605 from: arg_string(args, "from_target")?,
1606 to: arg_string(args, "to_target")?,
1607 }]
1608 } else if let Some(list) = args.get("repairs").and_then(Json::as_array) {
1609 list.iter()
1610 .map(|r| {
1611 Ok(omgbase_store::LinkRepair {
1612 from: arg_string(r, "from")?,
1613 to: arg_string(r, "to")?,
1614 })
1615 })
1616 .collect::<Result<_>>()?
1617 } else if let (Some(f), Some(t)) =
1618 (arg_str(args, "from_target"), arg_str(args, "to_target"))
1619 {
1620 vec![omgbase_store::LinkRepair {
1621 from: f.to_owned(),
1622 to: t.to_owned(),
1623 }]
1624 } else {
1625 Vec::new()
1626 };
1627 if repairs.is_empty() {
1628 return Err(SurfaceError::new(
1629 "target_missing",
1630 "links_repair requires `repairs` (array of {from,to}) or a `from_target`+`to_target` pair",
1631 ));
1632 }
1633 let plan = self.store.links_repair(
1634 &repo,
1635 &repairs,
1636 arg_str(args, "path_glob").filter(|g| !g.is_empty()),
1637 )?;
1638 let dry = arg_bool(args, "dry_run")?.unwrap_or(true);
1639 let res = self.apply_ops(&repo, root.as_deref(), plan.ops.clone(), name, dry)?;
1640 let mut m = Map::new();
1641 m.insert(
1642 "hits".to_owned(),
1643 Json::Array(plan.hits.iter().map(|h| json!({ "block": h.block, "path": h.path, "oldRaw": h.old_raw, "newRaw": h.new_raw })).collect()),
1644 );
1645 m.insert(
1646 "pairs".to_owned(),
1647 Json::Array(
1648 plan.pairs
1649 .iter()
1650 .map(|p| json!({ "from": p.from, "to": p.to, "hits": p.hits }))
1651 .collect(),
1652 ),
1653 );
1654 m.insert("applied".to_owned(), json!(!dry));
1655 Ok(merge(m, apply_json(&res)))
1656 }
1657 "links_stale" => {
1658 let (repo, _) = self.scope(args)?;
1659 let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
1660 if arg_bool(args, "summary")?.unwrap_or(false) {
1661 return links::links_stale_summary(&self.store, &repo, glob);
1662 }
1663 links::links_stale(&self.store, &repo, glob, arg_i64(args, "limit")?)
1664 }
1665 "docs_create" => {
1666 let (repo, root) = self.scope(args)?;
1667 let ctx = self.doc_ctx(&repo);
1668 let path = arg_string(args, "path")?;
1669 let markdown = arg_string(args, "markdown")?;
1670 let fm = arg_object(args, "frontmatter")?.cloned();
1671 let res = self.with_writes(root.as_deref(), |store, ds| {
1672 Ok(store.docs_create(&ctx, ds, &path, &markdown, fm.as_ref())?)
1673 })?;
1674 self.notify();
1675 Ok(doc_op_json(&res))
1676 }
1677 "docs_move" => {
1678 let (repo, root) = self.scope(args)?;
1679 let ctx = self.doc_ctx(&repo);
1680 let doc = arg_string(args, "doc")?;
1681 let to = arg_string(args, "to_path")?;
1682 let retarget = arg_bool(args, "retarget_inbound")?.unwrap_or(false);
1683 let res = self.with_writes(root.as_deref(), |store, ds| {
1684 Ok(store.docs_move(&ctx, ds, &doc, &to, retarget)?)
1685 })?;
1686 self.notify();
1687 Ok(json!({
1688 "docId": res.doc_id,
1689 "path": res.path,
1690 "committed": res.committed,
1691 "dangling": res.dangling.iter().map(omgbase_store::InboundLink::to_json).collect::<Vec<_>>(),
1692 "retargeted": res.retargeted.as_ref().map(|r| json!({ "blocks": r.blocks, "docs": r.docs })),
1693 }))
1694 }
1695 "docs_delete" => {
1696 let (repo, root) = self.scope(args)?;
1697 let ctx = self.doc_ctx(&repo);
1698 let doc = arg_string(args, "doc")?;
1699 let res = self.with_writes(root.as_deref(), |store, ds| {
1700 Ok(store.docs_delete(&ctx, ds, &doc)?)
1701 })?;
1702 self.notify();
1703 Ok(doc_op_json(&res))
1704 }
1705 "docs_set_meta" => {
1706 let (repo, root) = self.scope(args)?;
1707 let ctx = self.doc_ctx(&repo);
1708 let doc = arg_string(args, "doc")?;
1709 let set = arg_object(args, "set")?.cloned();
1710 let unset = if args.get("unset").is_some() {
1711 arg_strings(args, "unset")?
1712 } else {
1713 Vec::new()
1714 };
1715 let res = self.with_writes(root.as_deref(), |store, ds| {
1716 Ok(store.docs_set_meta(&ctx, ds, &doc, set.as_ref(), &unset)?)
1717 })?;
1718 self.notify();
1719 Ok(doc_op_json(&res))
1720 }
1721 "docs_plan_update" => {
1722 let (repo, root) = self.scope(args)?;
1723 self.require_root(root.as_deref())?;
1724 let doc = arg_string(args, "doc")?;
1725 let content = arg_string(args, "content")?;
1726 let opset = self
1727 .store
1728 .plan_update(&repo, &doc, &content, &self.config.clone())?;
1729 Ok(json!({ "opset": opset_json(&opset), "plan": render_opset_plan(&opset) }))
1730 }
1731 "docs_update" => {
1732 let (repo, root) = self.scope(args)?;
1733 let doc = arg_string(args, "doc")?;
1734 let content = arg_string(args, "content")?;
1735 let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1736 let reason = arg_str(args, "reason").map(str::to_owned);
1737 let origin = ApplyOrigin::new(ACTOR, reason.as_deref());
1738 let config = self.config.clone();
1739 let ts = self.now();
1740 let (opset, result) = self.with_writes(root.as_deref(), |store, ds| {
1741 Ok(store.docs_update(&repo, &doc, &content, &config, &origin, dry, ds, &ts)?)
1742 })?;
1743 if !dry {
1744 self.notify();
1745 }
1746 Ok(json!({
1747 "opset": opset_json(&opset),
1748 "plan": render_opset_plan(&opset),
1749 "result": result.map(|r| apply_json(&r)),
1750 }))
1751 }
1752 "observe" => {
1753 let (repo, _) = self.scope(args)?;
1754 let path = arg_string(args, "path")?;
1755 let content = arg_string(args, "content")?;
1756 let ts = self.now();
1757 let config = self.config.clone();
1758 let out = self
1759 .store
1760 .observe_one(&repo, &path, &content, &ts, &config)?;
1761 self.store.sweep_pool(&ts)?;
1762 self.notify();
1763 Ok(observe_json(&out))
1764 }
1765 "observe_many" => {
1766 let (repo, _) = self.scope(args)?;
1767 let files = args
1768 .get("files")
1769 .and_then(Json::as_array)
1770 .ok_or_else(|| bad_args("`files` must be an array of {path, content}"))?;
1771 let items: Vec<omgbase_store::BatchItem> = files
1772 .iter()
1773 .map(|f| {
1774 Ok(omgbase_store::BatchItem::observed(
1775 &arg_string(f, "path")?,
1776 &arg_string(f, "content")?,
1777 ))
1778 })
1779 .collect::<Result<_>>()?;
1780 let ts = self.now();
1781 let config = self.config.clone();
1782 let outcomes = self.store.observe_batch(&repo, &items, &ts, &config)?;
1783 self.store.sweep_pool(&ts)?;
1784 self.notify();
1785 let mut out = Vec::with_capacity(outcomes.len());
1786 for o in &outcomes {
1787 match o.as_observed() {
1788 Some(obs) => out.push(observe_json(obs)),
1789 None => {
1790 return Err(SurfaceError::other(format!(
1791 "observe_many: unexpected outcome for {}",
1792 o.path()
1793 )));
1794 }
1795 }
1796 }
1797 Ok(Json::Array(out))
1798 }
1799 "observe_delete" => {
1800 let (repo, _) = self.scope(args)?;
1801 let path = arg_string(args, "path")?;
1802 let ts = self.now();
1803 let out = self.store.observe_delete(&repo, &path, &ts)?;
1804 self.notify();
1805 Ok(json!({ "docId": out.doc_id, "path": out.path, "deleted": out.deleted() }))
1806 }
1807 "history_node" => history::history_node(
1808 &self.store,
1809 &arg_string(args, "id")?,
1810 arg_i64(args, "limit")?,
1811 ),
1812 "diff" => {
1813 let (repo, _) = self.scope(args)?;
1814 let doc_id = self.resolve_doc_id(&repo, arg_str(args, "doc"), None, None)?;
1815 history::diff_blocks(
1816 &self.store,
1817 &doc_id,
1818 &arg_string(args, "from_rev")?,
1819 &arg_string(args, "to_rev")?,
1820 )
1821 }
1822 "diff_unified" => {
1823 let (repo, _) = self.scope(args)?;
1824 let doc_ref = arg_string(args, "doc")?;
1825 let doc_id = self.resolve_doc_id(&repo, Some(&doc_ref), None, None)?;
1826 let revs = history::recent_revs(self.store.conn(), &doc_id)?;
1827 let to_rev = arg_str(args, "to_rev")
1828 .map(str::to_owned)
1829 .or_else(|| revs.first().cloned());
1830 let from_rev = arg_str(args, "from_rev")
1831 .map(str::to_owned)
1832 .or_else(|| revs.get(1).cloned())
1833 .or_else(|| revs.first().cloned());
1834 let (Some(from), Some(to)) = (from_rev, to_rev) else {
1835 return Err(SurfaceError::new(
1836 "target_missing",
1837 format!("no revisions to diff for {}", Json::String(doc_ref)),
1838 ));
1839 };
1840 let path: Option<String> = self
1841 .store
1842 .conn()
1843 .query_row(
1844 "SELECT path FROM docs WHERE doc_id = ?1",
1845 params![doc_id],
1846 |r| r.get(0),
1847 )
1848 .optional()?;
1849 let diff = history::diff_unified_text(&self.store, &doc_id, &from, &to)?;
1850 Ok(
1851 json!({ "doc": doc_id, "path": path.unwrap_or_default(), "from": from, "to": to, "diff": diff }),
1852 )
1853 }
1854 "docs_read_at" => {
1855 let (repo, _) = self.scope(args)?;
1856 let doc_id = self.resolve_doc_from_args(&repo, args)?;
1857 let rev = arg_string(args, "rev")?;
1858 read::docs_read_at(&self.store, &doc_id, &rev)?.ok_or_else(|| {
1859 SurfaceError::with_data(
1860 "target_missing",
1861 format!(
1862 "no revision {} for document {doc_id}",
1863 Json::String(rev.clone())
1864 ),
1865 json!({ "doc": doc_id, "rev": rev }),
1866 )
1867 })
1868 }
1869 "docs_history" => {
1870 let (repo, _) = self.scope(args)?;
1871 let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
1872 let doc = arg_str(args, "doc").filter(|d| !d.is_empty());
1873 if glob.is_none() && doc.is_none() {
1874 return Err(SurfaceError::new(
1875 "target_missing",
1876 "docs_history requires one of path_glob or doc",
1877 ));
1878 }
1879 let include_deleted = arg_bool(args, "include_deleted")?.unwrap_or(false);
1880 if let Some(d) = doc {
1881 if history::resolve_doc_row(self.store.conn(), &repo, d, include_deleted)?
1882 .is_none()
1883 {
1884 return Err(SurfaceError::new(
1885 "doc_missing",
1886 format!("no document for {}", Json::String(d.to_owned())),
1887 ));
1888 }
1889 }
1890 history::docs_history(
1891 &self.store,
1892 &repo,
1893 glob,
1894 doc,
1895 include_deleted,
1896 arg_i64(args, "limit")?,
1897 )
1898 }
1899 "changes_since" => {
1900 let (repo, _) = self.scope(args)?;
1901 let page = self.store.changes_since(
1902 &repo,
1903 arg_i64(args, "cursor")?.unwrap_or(0),
1904 arg_usize(args, "limit")?.unwrap_or(50),
1905 arg_str(args, "origin").filter(|o| !o.is_empty()),
1906 )?;
1907 Ok(page.to_json())
1908 }
1909 "repos_status" => {
1910 let (repo, root) = self.scope(args)?;
1911 let fs = RealFileSystem;
1912 let disk = root
1913 .as_deref()
1914 .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
1915 Ok(omgbase_sync::repos_status(&self.store, &repo, disk)?.to_json())
1916 }
1917 "sync_status" => {
1918 let (repo, root) = self.scope(args)?;
1919 let fs = RealFileSystem;
1920 let disk = root
1921 .as_deref()
1922 .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
1923 Ok(omgbase_sync::sync_status(&self.store, &repo, disk)?.to_json())
1924 }
1925 "repos" => {
1926 let rows = self.repo_rows()?;
1927 Ok(json!({
1928 "repos": rows.iter().map(|r| json!({ "slug": r.slug, "hasSource": r.root_path.is_some() })).collect::<Vec<_>>(),
1929 }))
1930 }
1931 other => Err(SurfaceError::other(format!("unknown tool {other}"))),
1932 }
1933 }
1934}
1935
1936#[must_use]
1940pub fn apply_json(res: &ApplyResult) -> Json {
1941 let mut m = Map::new();
1942 m.insert(
1943 "results".to_owned(),
1944 Json::Array(
1945 res.results
1946 .iter()
1947 .map(|r| {
1948 let mut o = Map::new();
1949 o.insert("ids".to_owned(), json!(r.ids));
1950 if let Some(rm) = &r.removed {
1951 o.insert("removed".to_owned(), json!(rm));
1952 }
1953 if let Some(mi) = &r.merged_into {
1954 o.insert("mergedInto".to_owned(), json!(mi));
1955 }
1956 Json::Object(o)
1957 })
1958 .collect(),
1959 ),
1960 );
1961 m.insert(
1962 "revisions".to_owned(),
1963 Json::Array(
1964 res.revisions
1965 .iter()
1966 .map(|r| json!({ "doc": r.doc, "path": r.path }))
1967 .collect(),
1968 ),
1969 );
1970 if let Some(diffs) = &res.diffs {
1971 let mut d = Map::new();
1972 for (path, diff) in diffs {
1973 d.insert(
1974 path.clone(),
1975 json!({ "before": diff.before, "after": diff.after }),
1976 );
1977 }
1978 m.insert("diffs".to_owned(), Json::Object(d));
1979 }
1980 m.insert("committed".to_owned(), json!(res.committed));
1981 Json::Object(m)
1982}
1983
1984fn doc_op_json(res: &omgbase_store::DocOpResult) -> Json {
1986 json!({ "docId": res.doc_id, "path": res.path, "committed": res.committed })
1987}
1988
1989#[must_use]
1992pub fn opset_json(opset: &Opset) -> Json {
1993 let mut m = Map::new();
1994 m.insert("version".to_owned(), json!(1));
1995 m.insert("kind".to_owned(), json!("doc_update"));
1996 m.insert(
1997 "target".to_owned(),
1998 json!({ "doc": opset.target_doc, "path": opset.target_path }),
1999 );
2000 m.insert(
2001 "precondition".to_owned(),
2002 json!({
2003 "doc": opset.precondition.doc,
2004 "path": opset.precondition.path,
2005 "baseRevision": opset.precondition.base_revision,
2006 "baseContentHash": opset.precondition.base_content_hash,
2007 }),
2008 );
2009 m.insert("matcherV".to_owned(), json!(opset.matcher_v));
2010 m.insert(
2011 "ops".to_owned(),
2012 Json::Array(
2013 opset
2014 .ops
2015 .iter()
2016 .map(|p| {
2017 let mut o = Map::new();
2018 let mut op = p.op.to_json();
2023 if let Some(m) = op.as_object_mut()
2024 && let Some(c) = m.remove("child_ids")
2025 {
2026 m.insert("childIds".to_owned(), c);
2027 }
2028 o.insert("op".to_owned(), op);
2029 o.insert("disposition".to_owned(), json!(p.disposition.as_str()));
2030 o.insert("blocks".to_owned(), json!(p.blocks));
2031 o.insert("confidence".to_owned(), json!(p.confidence));
2032 o.insert("reason".to_owned(), json!(p.reason));
2033 if let Some(d) = &p.detail {
2034 o.insert("detail".to_owned(), d.clone());
2035 }
2036 Json::Object(o)
2037 })
2038 .collect(),
2039 ),
2040 );
2041 if let Some(fm) = &opset.frontmatter {
2042 m.insert("frontmatter".to_owned(), json!({ "raw": fm }));
2043 }
2044 m.insert("summary".to_owned(), opset.summary.to_json());
2045 m.insert("converges".to_owned(), json!(opset.converges));
2046 m.insert("diagnostics".to_owned(), json!(opset.diagnostics));
2047 Json::Object(m)
2048}
2049
2050fn evidence_json(e: &omgbase_store::Evidence) -> Json {
2053 let mut m = Map::new();
2054 m.insert("rrf".to_owned(), json!(e.rrf));
2055 m.insert("boosts".to_owned(), e.boosts.to_json());
2056 if let Some(r) = e.fts_rank {
2057 m.insert("ftsRank".to_owned(), json!(r));
2058 }
2059 if let Some(r) = e.vector_rank {
2060 m.insert("vectorRank".to_owned(), json!(r));
2061 if let Some(c) = e.cosine {
2062 m.insert("cosine".to_owned(), json!(c));
2063 }
2064 }
2065 Json::Object(m)
2066}
2067
2068fn observe_json(o: &omgbase_store::ObserveOutcome) -> Json {
2071 json!({
2072 "docId": o.doc_id,
2073 "path": o.path,
2074 "rev": o.rev,
2075 "commitId": o.commit_id,
2076 "converged": o.converged,
2077 "echo": o.echo,
2078 "conflicted": o.conflicted,
2079 "dispositions": o.dispositions.iter().map(|(k, n)| json!({ "kind": k, "count": n })).collect::<Vec<_>>(),
2080 })
2081}
2082
2083fn verb(d: omgbase_store::mutate_kernel::PlanDisposition) -> &'static str {
2084 use omgbase_store::mutate_kernel::PlanDisposition as D;
2085 match d {
2086 D::Same => "KEEP ",
2087 D::Edited | D::EditedMoved => "UPDATE",
2088 D::Moved => "MOVE ",
2089 D::Inserted => "INSERT",
2090 D::Deleted => "REMOVE",
2091 D::SplitFrom => "SPLIT ",
2092 D::MergedInto => "MERGE ",
2093 D::CopiedFrom => "COPY ",
2094 D::Resurrected => "RESURR",
2095 D::BulkRewrite => "REWRITE",
2096 D::Retiled => "RETILE",
2097 }
2098}
2099
2100#[must_use]
2102pub fn render_opset_plan(opset: &Opset) -> String {
2103 let mut lines = Vec::new();
2104 for p in &opset.ops {
2105 let subject = p.blocks.first().map_or("(new)", String::as_str);
2106 let conf = p.confidence.map_or(String::new(), |c| format!(" ~{c:.2}"));
2107 let why = p
2108 .reason
2109 .as_ref()
2110 .map_or(String::new(), |r| format!(" [{r}]"));
2111 lines.push(format!(
2112 "{} {:<9} {}{conf}{why}",
2113 verb(p.disposition),
2114 subject,
2115 p.disposition.as_str()
2116 ));
2117 }
2118 let s = &opset.summary;
2119 lines.push(String::new());
2120 lines.push(format!(
2121 "preserved: {} updated: {} moved: {} created: {} removed: {} split: {} merged: {} ambiguous: {}",
2122 s.preserved, s.updated, s.moved, s.created, s.removed, s.split, s.merged, s.ambiguous
2123 ));
2124 if !opset.converges {
2125 lines.push(
2126 "WARNING: plan does not reproduce the proposed content exactly — will not apply."
2127 .to_owned(),
2128 );
2129 }
2130 lines.join("\n")
2131}
2132
2133#[cfg(test)]
2134mod tests {
2135 use super::*;
2136 use omgbase_store::{MemDocStore, SequentialMinter};
2137
2138 fn surface() -> Surface {
2139 let mut store =
2140 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
2141 let repo = store.create_repo("fixture").unwrap();
2142 let mut n = 0;
2143 Surface::new(store, &repo, None)
2144 .with_doc_store(Box::new(MemDocStore::new()))
2145 .with_clock(move || {
2146 n += 1;
2147 format!("2026-09-26T10:{n:02}:00.000Z")
2148 })
2149 }
2150
2151 #[test]
2152 fn catalog_lists_every_tool_of_the_table() {
2153 let names: Vec<&str> = tools().iter().map(|t| t.name).collect();
2154 for want in [
2155 "docs_outline",
2156 "docs_read",
2157 "docs_get_many",
2158 "nodes_get",
2159 "nodes_get_many",
2160 "read_ref",
2161 "docs_tree",
2162 "docs_list",
2163 "query_syntax",
2164 "query",
2165 "graph",
2166 "text_search",
2167 "resolve",
2168 "apply",
2169 "blocks_insert",
2170 "blocks_update",
2171 "blocks_move",
2172 "blocks_remove",
2173 "blocks_split",
2174 "blocks_merge",
2175 "tasks_complete",
2176 "node_set",
2177 "sections_append",
2178 "docs_append",
2179 "links_retarget",
2180 "links_stale",
2181 "links_repair",
2182 "docs_create",
2183 "docs_move",
2184 "docs_delete",
2185 "docs_set_meta",
2186 "docs_plan_update",
2187 "docs_update",
2188 "observe",
2189 "observe_many",
2190 "observe_delete",
2191 "history_node",
2192 "diff",
2193 "diff_unified",
2194 "docs_read_at",
2195 "docs_history",
2196 "changes_since",
2197 "repos_status",
2198 "sync_status",
2199 "repos",
2200 ] {
2201 assert!(names.contains(&want), "missing {want}");
2202 }
2203 assert_eq!(names.len(), 45);
2204 for t in tools() {
2205 assert_eq!(t.input_schema["type"], "object");
2206 }
2207 }
2208
2209 #[test]
2210 fn observe_read_and_query_round_trip() {
2211 let mut s = surface();
2212 let out = s.call("observe", json!({ "path": "a.md", "content": "---\nlayer: canon\n---\n# Title\n\nHello world.\n\n- [ ] task one\n" }));
2213 assert!(!out.is_error, "{}", out.body);
2214 assert_eq!(out.body["docId"], "d_0");
2215 assert_eq!(out.body["echo"], false);
2216 let read = s.call("docs_read", json!({ "doc": "a.md", "include_ids": true }));
2217 assert!(!read.is_error);
2218 assert_eq!(read.body["path"], "a.md");
2219 assert_eq!(read.body["properties"]["frontmatter"]["layer"], "canon");
2220 assert!(read.body["ids"].as_array().unwrap().len() >= 3);
2221 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" }));
2222 assert!(!q.is_error, "{}", q.body);
2223 assert_eq!(q.body["hits"][0]["$title"], "Title");
2224 assert_eq!(q.body["hits"][0]["layer"], "canon");
2225 assert_eq!(q.body["hits"][0]["t"][0]["value"], "task one");
2226 assert_eq!(q.body["hits"][0]["id"], "d_0");
2227 assert_eq!(q.body["consumer"], "collect");
2228 let c = s.call(
2229 "query",
2230 json!({ "query": "$repo.blocks count { where text(\"hello\") }" }),
2231 );
2232 assert_eq!(c.body["count"], 1);
2233 let bad = s.call(
2234 "query",
2235 json!({ "query": "from docs where path == \"a.md\"" }),
2236 );
2237 assert!(bad.is_error);
2238 assert_eq!(bad.body["error"], "filter_invalid");
2239 assert!(
2240 bad.body["message"]
2241 .as_str()
2242 .unwrap()
2243 .contains("did you mean the intrinsic $path")
2244 );
2245 let sem = s.call(
2246 "query",
2247 json!({ "query": "from docs where semantic(\"x\") > 0.5" }),
2248 );
2249 assert_eq!(sem.body["error"], "semantic_unavailable");
2250 let outline = s.call("docs_outline", json!({ "path": "a.md" }));
2251 let text = outline.body["text"].as_str().unwrap();
2252 assert!(text.starts_with("b_0 h1 Title §"), "{text}");
2253 assert!(text.contains("☐ task one"));
2254 let missing = s.call("docs_read", json!({ "doc": "nope.md" }));
2255 assert_eq!(missing.body["error"], "doc_missing");
2256 let repos = s.call("repos", json!({}));
2257 assert_eq!(repos.body["repos"][0]["slug"], "fixture");
2258 let unknown = s.call("docs_list", json!({ "repo": "zzz" }));
2259 assert_eq!(unknown.body["error"], "repo_not_found");
2260 }
2261
2262 #[test]
2263 fn writes_go_through_the_fixed_doc_store_and_fire_the_hook() {
2264 use std::cell::Cell;
2265 use std::rc::Rc;
2266 let fired = Rc::new(Cell::new(0));
2267 let f2 = Rc::clone(&fired);
2268 let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
2269 s.call(
2270 "observe",
2271 json!({ "path": "a.md", "content": "# T\n\nOne.\n" }),
2272 );
2273 assert_eq!(fired.get(), 1);
2274 let dry = s.call(
2275 "blocks_insert",
2276 json!({ "to": "a.md", "markdown": "Two.", "dry_run": true }),
2277 );
2278 assert!(!dry.is_error, "{}", dry.body);
2279 assert_eq!(dry.body["committed"], false);
2280 assert_eq!(fired.get(), 1, "a dry run never fires the hook");
2281 let wet = s.call("blocks_insert", json!({ "to": "a.md", "markdown": "Two." }));
2282 assert!(!wet.is_error, "{}", wet.body);
2283 assert_eq!(fired.get(), 2);
2284 let read = s.call("docs_read", json!({ "doc": "d_0" }));
2285 assert_eq!(read.body["content"], "# T\n\nOne.\n\nTwo.\n");
2286 let upd = s.call(
2287 "blocks_update",
2288 json!({ "block": "b_1", "markdown": "One, edited." }),
2289 );
2290 assert!(!upd.is_error, "{}", upd.body);
2291 assert_eq!(upd.body["id"], "b_1");
2292 let app = s.call(
2293 "sections_append",
2294 json!({ "heading": "T", "markdown": "Three." }),
2295 );
2296 assert!(!app.is_error, "{}", app.body);
2297 let amb = s.call(
2298 "sections_append",
2299 json!({ "heading": "Nope", "markdown": "x" }),
2300 );
2301 assert_eq!(amb.body["error"], "parent_missing");
2302 let hist = s.call("history_node", json!({ "id": "b_1" }));
2303 let entries = hist.body.as_array().unwrap();
2304 assert!(entries.len() >= 2, "{}", hist.body);
2305 assert_eq!(entries[0]["origin"], "api", "newest first");
2306 let du = s.call("diff_unified", json!({ "doc": "a.md" }));
2307 assert!(!du.is_error, "{}", du.body);
2308 assert!(du.body["diff"].as_str().unwrap().contains("+Three."));
2309 }
2310}