1use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::str::FromStr;
11
12use anyhow::{anyhow, bail, Context, Result};
13use chrono::Utc;
14use khive_runtime::portability::{ExportedEdge, ExportedEntity, KgArchive};
15use khive_runtime::{entity_fts_document, KhiveRuntime, RuntimeConfig};
16use khive_storage::types::Edge;
17use khive_storage::LinkId;
18use khive_types::{EdgeRelation, EntityKind};
19use serde::{Deserialize, Serialize};
20use uuid::Uuid;
21
22use crate::error::VcsError;
23use crate::hash::snapshot_id_for_archive;
24use crate::types::SnapshotId;
25
26#[derive(Debug, Serialize, Deserialize)]
28struct NdjsonEntity {
29 id: Uuid,
30 kind: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 entity_type: Option<String>,
33 name: String,
34 #[serde(default)]
35 description: Option<String>,
36 #[serde(default)]
37 properties: Option<serde_json::Value>,
38 #[serde(default)]
39 tags: Vec<String>,
40 #[serde(default)]
41 created_at: Option<String>,
42 #[serde(default)]
43 updated_at: Option<String>,
44}
45
46#[derive(Debug, Serialize, Deserialize)]
48struct NdjsonEdge {
49 edge_id: Uuid,
50 source: Uuid,
51 target: Uuid,
52 relation: String,
53 #[serde(default = "default_weight")]
54 weight: f64,
55 #[serde(default)]
58 #[allow(dead_code)]
60 properties: Option<serde_json::Value>,
61 #[serde(default)]
62 created_at: Option<String>,
63 #[serde(default)]
64 #[allow(dead_code)]
66 updated_at: Option<String>,
67}
68
69fn default_weight() -> f64 {
70 1.0
71}
72
73fn parse_ts_micros(s: Option<&str>) -> i64 {
76 s.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
77 .map(|dt| dt.timestamp_micros())
78 .unwrap_or_else(|| chrono::Utc::now().timestamp_micros())
79}
80
81#[derive(Debug, Serialize)]
83pub struct SyncReport {
84 pub entities: usize,
85 pub edges: usize,
86 pub db_path: String,
87}
88
89#[derive(Clone, PartialEq, Eq, Hash)]
98pub struct RemoteName(String);
99
100impl std::fmt::Debug for RemoteName {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 std::fmt::Debug::fmt(&self.0, f)
106 }
107}
108
109impl RemoteName {
110 pub fn parse(raw: impl Into<String>) -> Result<Self, VcsError> {
117 let raw = raw.into();
118 let valid = !raw.is_empty()
119 && raw != "."
120 && raw != ".."
121 && raw
122 .chars()
123 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
124 && !raw.contains('/')
125 && !raw.contains('\\');
126 if !valid {
127 return Err(VcsError::InvalidRemoteName(raw));
128 }
129 Ok(Self(raw))
130 }
131
132 pub fn as_str(&self) -> &str {
134 &self.0
135 }
136}
137
138impl std::fmt::Display for RemoteName {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 f.write_str(&self.0)
141 }
142}
143
144#[derive(Debug, Clone)]
147pub struct RemoteConfig {
148 pub name: RemoteName,
152 pub url: String,
154 pub git_ref: String,
156 pub namespace: String,
158 pub pin: Option<SnapshotId>,
161}
162
163#[derive(Debug, Serialize)]
165pub struct RemoteSyncReport {
166 pub entities: usize,
167 pub edges: usize,
168 pub cache_dir: String,
170 pub meta_path: String,
172 pub content_hash: String,
174 pub repinned: bool,
177}
178
179#[derive(Debug, Serialize)]
181struct MetaJson {
182 fetched_at: String,
184 git_ref: String,
186 commit_sha: String,
188 content_hash: String,
190}
191
192pub async fn run_sync_remote(
195 repo_root: &Path,
196 remote: &RemoteConfig,
197 repin: bool,
198) -> Result<RemoteSyncReport> {
199 let state_dir = repo_root.join(".khive/state/remote-staging");
201 std::fs::create_dir_all(&state_dir)
202 .with_context(|| format!("creating staging dir {}", state_dir.display()))?;
203 let staging = tempfile::TempDir::new_in(&state_dir).context("creating staging temp dir")?;
204 let staging_path = staging.path().to_path_buf();
205
206 let entities_ndjson: Vec<NdjsonEntity>;
208 let edges_ndjson: Vec<NdjsonEdge>;
209 let commit_sha: String;
210
211 {
212 let clone_out = Command::new("git")
215 .args([
216 "clone",
217 "--depth=1",
218 "--filter=blob:none",
219 "--no-checkout",
220 "--branch",
221 &remote.git_ref,
222 ])
223 .arg(&remote.url)
224 .arg(&staging_path)
225 .output()
226 .context("running git clone")?;
227
228 if !clone_out.status.success() {
229 let stderr = String::from_utf8_lossy(&clone_out.stderr);
230 let safe = redact_git_stderr(stderr.trim());
231 return Err(anyhow!(
232 "git clone failed for remote {:?}: {}",
233 remote.name,
234 safe
235 ));
236 }
237
238 let rev_out = Command::new("git")
240 .args(["rev-parse", "HEAD"])
241 .current_dir(&staging_path)
242 .output()
243 .context("running git rev-parse HEAD")?;
244 commit_sha = String::from_utf8_lossy(&rev_out.stdout).trim().to_string();
245
246 run_git_in(&staging_path, &["sparse-checkout", "init", "--cone"])
248 .context("git sparse-checkout init")?;
249 run_git_in(
250 &staging_path,
251 &[
252 "sparse-checkout",
253 "set",
254 ".khive/kg/entities.ndjson",
255 ".khive/kg/edges.ndjson",
256 ],
257 )
258 .context("git sparse-checkout set")?;
259 run_git_in(&staging_path, &["checkout"]).context("git checkout")?;
260
261 let entities_path = staging_path.join(".khive/kg/entities.ndjson");
263 let edges_path = staging_path.join(".khive/kg/edges.ndjson");
264
265 entities_ndjson = read_entities(&entities_path)
266 .with_context(|| format!("reading staged {}", entities_path.display()))?;
267 edges_ndjson = read_edges(&edges_path)
268 .with_context(|| format!("reading staged {}", edges_path.display()))?;
269 }
270 let archive = build_kg_archive(&remote.namespace, &entities_ndjson, &edges_ndjson)
276 .with_context(|| format!("validating archive for remote {:?}", remote.name))?;
277 let actual_hash = snapshot_id_for_archive(&archive)
278 .map_err(|e| anyhow!("hashing archive for remote {:?}: {}", remote.name, e))?;
279
280 if let Some(expected) = &remote.pin {
282 if !repin && actual_hash != *expected {
283 return Err(anyhow!(VcsError::HashMismatch {
284 expected: expected.clone(),
285 actual: actual_hash.clone(),
286 })
287 .context(format!(
288 "remote {:?}: hash mismatch — use `--repin` to accept the new content \
289 after independently verifying it (actual hash: {})",
290 remote.name,
291 actual_hash.as_str()
292 )));
293 }
294 }
295
296 let meta = MetaJson {
298 fetched_at: Utc::now().to_rfc3339(),
299 git_ref: remote.git_ref.clone(),
300 commit_sha,
301 content_hash: actual_hash.as_str().to_string(),
302 };
303
304 let remotes_root = repo_root.join(".khive/kg/remotes");
305 let cache_dir = remotes_root.join(remote.name.as_str());
306 let published = publish_remote_cache(
307 &remotes_root,
308 remote.name.as_str(),
309 &entities_ndjson,
310 &edges_ndjson,
311 &meta,
312 #[cfg(test)]
313 None,
314 )
315 .with_context(|| format!("publishing cache for remote {:?}", remote.name))?;
316 debug_assert_eq!(published, cache_dir);
317
318 drop(staging);
320
321 Ok(RemoteSyncReport {
322 entities: entities_ndjson.len(),
323 edges: edges_ndjson.len(),
324 cache_dir: cache_dir.to_string_lossy().into_owned(),
325 meta_path: cache_dir.join("meta.json").to_string_lossy().into_owned(),
326 content_hash: actual_hash.as_str().to_string(),
327 repinned: repin,
328 })
329}
330
331#[cfg(test)]
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
335enum PublishFailAt {
336 AfterEntities,
337 AfterEdges,
338 AfterMeta,
339 BeforeSwap,
340}
341
342fn publish_remote_cache(
352 remotes_root: &Path,
353 name: &str,
354 entities: &[NdjsonEntity],
355 edges: &[NdjsonEdge],
356 meta: &MetaJson,
357 #[cfg(test)] fail_at: Option<PublishFailAt>,
358) -> Result<PathBuf> {
359 std::fs::create_dir_all(remotes_root)
360 .with_context(|| format!("creating {}", remotes_root.display()))?;
361 let staging = tempfile::TempDir::new_in(remotes_root).context("creating staging dir")?;
362
363 write_sorted_entities(&staging.path().join("entities.ndjson"), entities)
364 .context("writing staged entities.ndjson")?;
365 #[cfg(test)]
366 if fail_at == Some(PublishFailAt::AfterEntities) {
367 anyhow::bail!("injected failure after staged entities write");
368 }
369
370 write_sorted_edges(&staging.path().join("edges.ndjson"), edges)
371 .context("writing staged edges.ndjson")?;
372 #[cfg(test)]
373 if fail_at == Some(PublishFailAt::AfterEdges) {
374 anyhow::bail!("injected failure after staged edges write");
375 }
376
377 let meta_json = serde_json::to_string_pretty(meta).context("serializing meta.json")?;
378 std::fs::write(staging.path().join("meta.json"), meta_json.as_bytes())
379 .context("writing staged meta.json")?;
380 fsync_dir_best_effort(staging.path());
381 #[cfg(test)]
382 if fail_at == Some(PublishFailAt::AfterMeta) {
383 anyhow::bail!("injected failure after staged meta write, before swap");
384 }
385
386 let cache_dir = remotes_root.join(name);
387 #[cfg(test)]
388 if fail_at == Some(PublishFailAt::BeforeSwap) {
389 anyhow::bail!("injected failure before swap");
390 }
391 atomic_replace_dir(staging.path(), &cache_dir)?;
392 fsync_dir_best_effort(remotes_root);
393
394 Ok(cache_dir)
395}
396
397fn atomic_replace_dir(new_dir: &Path, target_dir: &Path) -> Result<()> {
408 if !target_dir.exists() {
409 std::fs::rename(new_dir, target_dir).with_context(|| {
410 format!("renaming {} -> {}", new_dir.display(), target_dir.display())
411 })?;
412 return Ok(());
413 }
414
415 let backup = target_dir.with_file_name(format!(
416 "{}.replaced-{}",
417 target_dir
418 .file_name()
419 .and_then(|n| n.to_str())
420 .unwrap_or("cache"),
421 std::process::id()
422 ));
423 let _ = std::fs::remove_dir_all(&backup);
424
425 std::fs::rename(target_dir, &backup).with_context(|| {
426 format!(
427 "backing up existing cache {} -> {}",
428 target_dir.display(),
429 backup.display()
430 )
431 })?;
432
433 match std::fs::rename(new_dir, target_dir) {
434 Ok(()) => {
435 let _ = std::fs::remove_dir_all(&backup);
436 Ok(())
437 }
438 Err(e) => {
439 let _ = std::fs::rename(&backup, target_dir);
441 Err(e).with_context(|| {
442 format!(
443 "renaming {} -> {} (old cache restored)",
444 new_dir.display(),
445 target_dir.display()
446 )
447 })
448 }
449 }
450}
451
452fn fsync_dir_best_effort(dir: &Path) {
457 if let Ok(f) = std::fs::File::open(dir) {
458 let _ = f.sync_all();
459 }
460}
461
462fn redact_git_stderr(raw: &str) -> String {
467 let mut out = String::with_capacity(raw.len());
468 let bytes = raw.as_bytes();
469 let mut i = 0;
470 while i < bytes.len() {
471 if bytes[i..].starts_with(b"://") {
472 let scheme_start = {
474 let mut s = i;
475 while s > 0 && {
476 let b = bytes[s - 1];
477 b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.'
478 } {
479 s -= 1;
480 }
481 s
482 };
483 let already_appended = i - scheme_start;
484 out.truncate(out.len() - already_appended);
485 let rest_start = i + 3;
487 let url_end = bytes[rest_start..]
488 .iter()
489 .position(|&b| b.is_ascii_whitespace())
490 .map(|p| rest_start + p)
491 .unwrap_or(bytes.len());
492 out.push_str("<url-redacted>");
493 i = url_end;
494 } else if is_scp_remote_start(bytes, i) {
495 let token_start = scan_back_word(bytes, i);
499 let already_appended = i - token_start;
500 out.truncate(out.len() - already_appended);
501 let token_end = bytes[i..]
503 .iter()
504 .position(|&b| b.is_ascii_whitespace())
505 .map(|p| i + p)
506 .unwrap_or(bytes.len());
507 out.push_str("<url-redacted>");
508 i = token_end;
509 } else {
510 out.push(bytes[i] as char);
511 i += 1;
512 }
513 }
514 out
515}
516
517fn is_scp_remote_start(bytes: &[u8], i: usize) -> bool {
521 if bytes[i] != b'@' {
522 return false;
523 }
524 if i == 0 || bytes[i - 1].is_ascii_whitespace() {
526 return false;
527 }
528 let after_at = &bytes[i + 1..];
530 let colon_pos = after_at
532 .iter()
533 .position(|&b| b == b':' || b.is_ascii_whitespace());
534 match colon_pos {
535 Some(p) if after_at[p] == b':' => {
536 let next = p + 1;
539 if next >= after_at.len() {
540 return false;
541 }
542 let ch = after_at[next];
543 !ch.is_ascii_whitespace() && ch != b':'
544 }
545 _ => false,
546 }
547}
548
549fn scan_back_word(bytes: &[u8], i: usize) -> usize {
552 let mut s = i;
553 while s > 0 {
554 let b = bytes[s - 1];
555 if b.is_ascii_whitespace() || b == b'\'' || b == b'"' {
556 break;
557 }
558 s -= 1;
559 }
560 s
561}
562
563fn run_git_in(dir: &Path, args: &[&str]) -> Result<()> {
565 let out = Command::new("git")
566 .args(args)
567 .current_dir(dir)
568 .output()
569 .with_context(|| format!("running git {}", args.join(" ")))?;
570 if !out.status.success() {
571 let stderr = String::from_utf8_lossy(&out.stderr);
572 let safe = redact_git_stderr(stderr.trim());
573 return Err(anyhow!("git {} failed: {}", args.join(" "), safe));
574 }
575 Ok(())
576}
577
578fn build_kg_archive(
584 namespace: &str,
585 entities: &[NdjsonEntity],
586 edges: &[NdjsonEdge],
587) -> Result<KgArchive> {
588 let now = Utc::now();
589 let exported_entities: Vec<ExportedEntity> = entities
590 .iter()
591 .map(|e| ExportedEntity {
592 id: e.id,
593 kind: e.kind.clone(),
594 entity_type: e.entity_type.clone(),
595 name: e.name.clone(),
596 description: e.description.clone(),
597 properties: e.properties.clone(),
598 tags: e.tags.clone(),
599 created_at: e
600 .created_at
601 .as_deref()
602 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
603 .map(|dt| dt.with_timezone(&Utc))
604 .unwrap_or(now),
605 updated_at: e
606 .updated_at
607 .as_deref()
608 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
609 .map(|dt| dt.with_timezone(&Utc))
610 .unwrap_or(now),
611 })
612 .collect();
613
614 let mut exported_edges: Vec<ExportedEdge> = Vec::with_capacity(edges.len());
615 for e in edges {
616 let relation: EdgeRelation = e
617 .relation
618 .parse()
619 .map_err(|err| anyhow!("invalid edge relation {:?}: {}", e.relation, err))?;
620 exported_edges.push(ExportedEdge {
621 edge_id: e.edge_id,
622 source: e.source,
623 target: e.target,
624 relation,
625 weight: e.weight,
626 });
627 }
628
629 Ok(KgArchive {
630 format: "khive-kg".into(),
631 version: "0.1".into(),
632 namespace: namespace.to_string(),
633 exported_at: now,
634 entities: exported_entities,
635 edges: exported_edges,
636 })
637}
638
639fn write_sorted_entities(path: &Path, records: &[NdjsonEntity]) -> Result<()> {
644 let mut sorted: Vec<&NdjsonEntity> = records.iter().collect();
645 sorted.sort_by(|a, b| {
646 a.id.to_string()
647 .to_ascii_lowercase()
648 .cmp(&b.id.to_string().to_ascii_lowercase())
649 });
650 let mut lines = Vec::with_capacity(sorted.len());
651 for r in sorted {
652 let line = serde_json::to_string(r).context("serializing entity")?;
653 lines.push(line);
654 }
655 std::fs::write(path, lines.join("\n")).context("writing entities file")?;
656 Ok(())
657}
658
659fn write_sorted_edges(path: &Path, records: &[NdjsonEdge]) -> Result<()> {
664 let mut sorted: Vec<&NdjsonEdge> = records.iter().collect();
665 sorted.sort_by(|a, b| {
666 let ak = (
667 a.source.to_string(),
668 a.target.to_string(),
669 a.relation.clone(),
670 );
671 let bk = (
672 b.source.to_string(),
673 b.target.to_string(),
674 b.relation.clone(),
675 );
676 ak.cmp(&bk)
677 });
678 let mut lines = Vec::with_capacity(sorted.len());
679 for r in sorted {
680 let line = serde_json::to_string(r).context("serializing edge")?;
681 lines.push(line);
682 }
683 std::fs::write(path, lines.join("\n")).context("writing edges file")?;
684 Ok(())
685}
686
687fn validate_ndjson_records(entities: &[NdjsonEntity], edges: &[NdjsonEdge]) -> Result<()> {
696 let mut entity_ids: HashSet<Uuid> = HashSet::with_capacity(entities.len());
697 let mut prev_entity_key: Option<String> = None;
698 for (i, e) in entities.iter().enumerate() {
699 EntityKind::from_str(&e.kind)
700 .map_err(|_| anyhow!("entity {i} ({}): unknown kind {:?}", e.id, e.kind))?;
701
702 if !entity_ids.insert(e.id) {
703 bail!("entity {i}: duplicate entity id {}", e.id);
704 }
705
706 for (field, value) in [("created_at", &e.created_at), ("updated_at", &e.updated_at)] {
707 if let Some(s) = value {
708 chrono::DateTime::parse_from_rfc3339(s)
709 .with_context(|| format!("entity {i} ({}): invalid {field} {s:?}", e.id))?;
710 }
711 }
712
713 let key = e.id.to_string().to_ascii_lowercase();
714 if let Some(prev) = &prev_entity_key {
715 if key < *prev {
716 bail!(
717 "entities.ndjson is not sorted: entity {i} ({}) is out of order",
718 e.id
719 );
720 }
721 }
722 prev_entity_key = Some(key);
723 }
724
725 let mut edge_ids: HashSet<Uuid> = HashSet::with_capacity(edges.len());
726 let mut triples: HashSet<(Uuid, Uuid, EdgeRelation)> = HashSet::with_capacity(edges.len());
727 let mut prev_edge_key: Option<(String, String, String)> = None;
728 for (i, r) in edges.iter().enumerate() {
729 let relation = r.relation.parse::<EdgeRelation>().with_context(|| {
730 format!(
731 "invalid edge relation {:?} at record {} — sync aborted before any DB write",
732 r.relation,
733 i + 1
734 )
735 })?;
736
737 if !r.weight.is_finite() || !(0.0..=1.0).contains(&r.weight) {
738 bail!(
739 "edge {i} ({}): weight {} out of range; must be finite and in [0.0, 1.0]",
740 r.edge_id,
741 r.weight
742 );
743 }
744
745 if !edge_ids.insert(r.edge_id) {
746 bail!("edge {i}: duplicate edge id {}", r.edge_id);
747 }
748
749 if !triples.insert((r.source, r.target, relation)) {
750 bail!(
751 "edge {i} ({}): duplicate edge triple (source={}, target={}, relation={:?})",
752 r.edge_id,
753 r.source,
754 r.target,
755 relation
756 );
757 }
758
759 if !entity_ids.contains(&r.source) {
760 bail!(
761 "edge {i} ({}): dangling source {} — no matching entity",
762 r.edge_id,
763 r.source
764 );
765 }
766 if !entity_ids.contains(&r.target) {
767 bail!(
768 "edge {i} ({}): dangling target {} — no matching entity",
769 r.edge_id,
770 r.target
771 );
772 }
773
774 for (field, value) in [("created_at", &r.created_at), ("updated_at", &r.updated_at)] {
775 if let Some(s) = value {
776 chrono::DateTime::parse_from_rfc3339(s)
777 .with_context(|| format!("edge {i} ({}): invalid {field} {s:?}", r.edge_id))?;
778 }
779 }
780
781 let key = (
782 r.source.to_string(),
783 r.target.to_string(),
784 r.relation.clone(),
785 );
786 if let Some(prev) = &prev_edge_key {
787 if key < *prev {
788 bail!(
789 "edges.ndjson is not sorted: edge {i} ({}) is out of order",
790 r.edge_id
791 );
792 }
793 }
794 prev_edge_key = Some(key);
795 }
796
797 Ok(())
798}
799
800pub async fn run_sync(repo_root: &Path, db_path: &Path, namespace: &str) -> Result<SyncReport> {
811 let entities_path = repo_root.join(".khive/kg/entities.ndjson");
812 let edges_path = repo_root.join(".khive/kg/edges.ndjson");
813
814 let entity_records = read_entities(&entities_path)
815 .with_context(|| format!("reading {}", entities_path.display()))?;
816 let edge_records =
817 read_edges(&edges_path).with_context(|| format!("reading {}", edges_path.display()))?;
818
819 validate_ndjson_records(&entity_records, &edge_records).context(
823 "validating ADR-020 KG NDJSON before DB rebuild — sync aborted before any DB write",
824 )?;
825
826 let tmp_path = with_extension_suffix(db_path, ".tmp");
827 let _ = std::fs::remove_file(&tmp_path);
828
829 let ns = khive_types::Namespace::parse(namespace)
833 .map_err(|e| anyhow!("invalid namespace {namespace:?}: {e}"))?;
834 let config = RuntimeConfig {
835 db_path: Some(tmp_path.clone()),
836 default_namespace: ns,
837 embedding_model: None,
838 ..RuntimeConfig::default()
839 };
840 let runtime = KhiveRuntime::new(config)
841 .with_context(|| format!("building runtime for {}", tmp_path.display()))?;
842
843 let entity_count = upsert_entities(&runtime, namespace, entity_records).await?;
844 let edge_count = upsert_edges(&runtime, namespace, edge_records).await?;
845
846 checkpoint_wal(&runtime)
852 .await
853 .context("checkpoint WAL before rename")?;
854
855 drop(runtime);
857
858 if let Some(parent) = db_path.parent() {
859 std::fs::create_dir_all(parent)
860 .with_context(|| format!("creating {}", parent.display()))?;
861 }
862 std::fs::rename(&tmp_path, db_path)
863 .with_context(|| format!("renaming {} -> {}", tmp_path.display(), db_path.display()))?;
864
865 Ok(SyncReport {
866 entities: entity_count,
867 edges: edge_count,
868 db_path: db_path.to_string_lossy().into_owned(),
869 })
870}
871
872fn with_extension_suffix(p: &Path, suffix: &str) -> PathBuf {
873 let mut s = p.as_os_str().to_owned();
874 s.push(suffix);
875 PathBuf::from(s)
876}
877
878fn read_entities(path: &Path) -> Result<Vec<NdjsonEntity>> {
879 if !path.exists() {
880 return Ok(Vec::new());
881 }
882 let text = std::fs::read_to_string(path)?;
883 let mut out = Vec::new();
884 for (i, line) in text.lines().enumerate() {
885 let trimmed = line.trim();
886 if trimmed.is_empty() {
887 continue;
888 }
889 let e: NdjsonEntity = serde_json::from_str(trimmed)
890 .with_context(|| format!("parsing entity at line {}", i + 1))?;
891 out.push(e);
892 }
893 Ok(out)
894}
895
896fn read_edges(path: &Path) -> Result<Vec<NdjsonEdge>> {
897 if !path.exists() {
898 return Ok(Vec::new());
899 }
900 let text = std::fs::read_to_string(path)?;
901 let mut out = Vec::new();
902 for (i, line) in text.lines().enumerate() {
903 let trimmed = line.trim();
904 if trimmed.is_empty() {
905 continue;
906 }
907 let e: NdjsonEdge = serde_json::from_str(trimmed)
908 .with_context(|| format!("parsing edge at line {}", i + 1))?;
909 out.push(e);
910 }
911 Ok(out)
912}
913
914async fn checkpoint_wal(runtime: &KhiveRuntime) -> Result<()> {
915 let mut writer = runtime.backend().sql().writer().await?;
916 writer
922 .execute_script_top_level("PRAGMA wal_checkpoint(TRUNCATE);".to_string())
923 .await?;
924 Ok(())
925}
926
927#[cfg(not(test))]
933const SYNC_CHUNK_SIZE: usize = 10_000;
934#[cfg(test)]
935const SYNC_CHUNK_SIZE: usize = 5;
936
937async fn upsert_entities(
938 runtime: &KhiveRuntime,
939 namespace: &str,
940 records: Vec<NdjsonEntity>,
941) -> Result<usize> {
942 let ns = khive_types::Namespace::parse(namespace)
943 .map_err(|e| anyhow!("invalid namespace {namespace:?}: {e}"))?;
944 let token = runtime.authorize(ns)?;
945 let store = runtime.entities(&token).context("opening entity store")?;
946 let text = runtime.text(&token).context("opening text store")?;
947
948 let mut count = 0usize;
953 for chunk in records.chunks(SYNC_CHUNK_SIZE) {
954 let mut entities_chunk = Vec::with_capacity(chunk.len());
955 let mut docs_chunk = Vec::with_capacity(chunk.len());
956 for r in chunk {
957 let created_at = parse_ts_micros(r.created_at.as_deref());
958 let updated_at = parse_ts_micros(r.updated_at.as_deref());
959 let entity = khive_storage::entity::Entity {
960 id: r.id,
961 namespace: namespace.to_string(),
962 kind: r.kind.clone(),
963 entity_type: r.entity_type.clone(),
964 name: r.name.clone(),
965 description: r.description.clone(),
966 properties: r.properties.clone(),
967 tags: r.tags.clone(),
968 created_at,
969 updated_at,
970 deleted_at: None,
971 merge_event_id: None,
972 merged_into: None,
973 content_ref: None,
974 };
975 let fts_doc = entity_fts_document(&entity);
978 entities_chunk.push(entity);
979 docs_chunk.push(fts_doc);
980 }
981
982 let summary = store
984 .upsert_entities(entities_chunk)
985 .await
986 .context("batch upsert entities")?;
987 if summary.failed > 0 {
988 return Err(anyhow!(
989 "entity write: {}/{} rows failed (first: {})",
990 summary.failed,
991 summary.attempted,
992 summary.first_error
993 ));
994 }
995 count += summary.affected as usize;
996
997 let summary = text
1001 .upsert_documents(docs_chunk)
1002 .await
1003 .context("batch FTS upsert")?;
1004 if summary.failed > 0 {
1005 return Err(anyhow!(
1006 "FTS write: {}/{} docs failed in chunk",
1007 summary.failed,
1008 summary.attempted
1009 ));
1010 }
1011 }
1012 Ok(count)
1013}
1014
1015async fn upsert_edges(
1016 runtime: &KhiveRuntime,
1017 namespace: &str,
1018 records: Vec<NdjsonEdge>,
1019) -> Result<usize> {
1020 let ns = khive_types::Namespace::parse(namespace)
1021 .map_err(|e| anyhow!("invalid namespace {namespace:?}: {e}"))?;
1022 let token = runtime.authorize(ns)?;
1023 let graph = runtime.graph(&token).context("opening graph store")?;
1024
1025 let mut count = 0usize;
1032 for chunk in records.chunks(SYNC_CHUNK_SIZE) {
1033 let mut edge_chunk = Vec::with_capacity(chunk.len());
1034 for r in chunk {
1035 let relation: EdgeRelation = r
1036 .relation
1037 .parse()
1038 .map_err(|e| anyhow!("invalid relation {:?}: {}", r.relation, e))?;
1039 let created_at =
1040 chrono::DateTime::from_timestamp_micros(parse_ts_micros(r.created_at.as_deref()))
1041 .unwrap_or_else(chrono::Utc::now);
1042 let edge = Edge {
1043 id: LinkId::from(r.edge_id),
1044 namespace: namespace.to_string(),
1045 source_id: r.source,
1046 target_id: r.target,
1047 relation,
1048 weight: r.weight,
1049 created_at,
1050 updated_at: created_at,
1051 deleted_at: None,
1052 metadata: None,
1053 target_backend: None,
1054 };
1055 edge_chunk.push(edge);
1056 }
1057 let summary = graph
1058 .upsert_edges(edge_chunk)
1059 .await
1060 .context("batch upsert edges")?;
1061 count += summary.affected as usize;
1062 }
1063 Ok(count)
1064}
1065
1066#[cfg(test)]
1073mod tests {
1074 use super::*;
1075 use tempfile::TempDir;
1076
1077 fn make_git_remote(dir: &Path, entities_ndjson: &str, edges_ndjson: &str) -> String {
1082 let kg_dir = dir.join(".khive/kg");
1083 std::fs::create_dir_all(&kg_dir).unwrap();
1084 std::fs::write(kg_dir.join("entities.ndjson"), entities_ndjson).unwrap();
1085 std::fs::write(kg_dir.join("edges.ndjson"), edges_ndjson).unwrap();
1086
1087 run_git(dir, &["init", "-b", "main"]);
1089 run_git(dir, &["config", "user.email", "test@example.com"]);
1090 run_git(dir, &["config", "user.name", "Test"]);
1091 run_git(dir, &["add", "-A"]);
1092 run_git(dir, &["commit", "-m", "init"]);
1093
1094 dir.to_string_lossy().into_owned()
1095 }
1096
1097 fn run_git(dir: &Path, args: &[&str]) {
1098 let status = Command::new("git")
1101 .args(["-c", "core.hooksPath=/dev/null"])
1102 .args(args)
1103 .current_dir(dir)
1104 .status()
1105 .unwrap_or_else(|e| panic!("git {} failed to spawn: {e}", args.join(" ")));
1106 assert!(
1107 status.success(),
1108 "git {} exited with {}",
1109 args.join(" "),
1110 status
1111 );
1112 }
1113
1114 fn compute_pin(entities_ndjson: &str, edges_ndjson: &str, namespace: &str) -> SnapshotId {
1117 let tmp = TempDir::new().unwrap();
1118 let kg = tmp.path().join(".khive/kg");
1119 std::fs::create_dir_all(&kg).unwrap();
1120 std::fs::write(kg.join("entities.ndjson"), entities_ndjson).unwrap();
1121 std::fs::write(kg.join("edges.ndjson"), edges_ndjson).unwrap();
1122
1123 let entities = read_entities(&kg.join("entities.ndjson")).unwrap();
1124 let edges = read_edges(&kg.join("edges.ndjson")).unwrap();
1125 let archive = build_kg_archive(namespace, &entities, &edges).unwrap();
1126 snapshot_id_for_archive(&archive).unwrap()
1127 }
1128
1129 fn write_repo(dir: &Path, entities_ndjson: &str, edges_ndjson: &str) {
1132 let kg_dir = dir.join(".khive/kg");
1133 std::fs::create_dir_all(&kg_dir).unwrap();
1134 std::fs::write(kg_dir.join("entities.ndjson"), entities_ndjson).unwrap();
1135 std::fs::write(kg_dir.join("edges.ndjson"), edges_ndjson).unwrap();
1136 }
1137
1138 #[tokio::test]
1139 async fn sync_empty_ndjson_produces_real_sqlite_file() {
1140 let tmp = TempDir::new().unwrap();
1141 let repo = tmp.path();
1142 let db_path = repo.join(".khive/state/working.db");
1143 write_repo(repo, "", "");
1144
1145 let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
1146 assert_eq!(report.entities, 0);
1147 assert_eq!(report.edges, 0);
1148
1149 let bytes = std::fs::read(&db_path).unwrap();
1150 assert!(!bytes.is_empty(), "DB file must be non-empty after sync");
1151 assert!(
1152 bytes.starts_with(b"SQLite format 3\0"),
1153 "DB file must start with SQLite magic header, got {:?}",
1154 &bytes[..bytes.len().min(20)]
1155 );
1156 }
1157
1158 #[tokio::test]
1159 async fn sync_imports_entities_and_edges_into_real_db() {
1160 let tmp = TempDir::new().unwrap();
1161 let repo = tmp.path();
1162 let db_path = repo.join(".khive/state/working.db");
1163
1164 let id_a = "11111111-1111-1111-1111-111111111111";
1165 let id_b = "22222222-2222-2222-2222-222222222222";
1166 let edge_id = "33333333-3333-3333-3333-333333333333";
1167
1168 let line_a = format!(
1169 r#"{{"id":"{id_a}","kind":"concept","name":"Alpha","properties":{{}},"tags":[]}}"#
1170 );
1171 let line_b = format!(
1172 r#"{{"id":"{id_b}","kind":"concept","name":"Beta","properties":{{}},"tags":[]}}"#
1173 );
1174 let entities = format!("{line_a}\n{line_b}\n");
1175 let edges = format!(
1176 r#"{{"edge_id":"{edge_id}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":1.0,"properties":{{}}}}"#
1177 );
1178 write_repo(repo, &entities, &edges);
1179
1180 let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
1181 assert_eq!(report.entities, 2);
1182 assert_eq!(report.edges, 1);
1183
1184 let ns = khive_types::Namespace::parse("test-ns").unwrap();
1185 let config = RuntimeConfig {
1186 db_path: Some(db_path.clone()),
1187 default_namespace: ns.clone(),
1188 embedding_model: None,
1189 ..RuntimeConfig::default()
1190 };
1191 let rt = KhiveRuntime::new(config).unwrap();
1192 let token = rt.authorize(ns).unwrap();
1193 let alpha = rt
1194 .entities(&token)
1195 .unwrap()
1196 .get_entity(id_a.parse().unwrap())
1197 .await
1198 .unwrap()
1199 .expect("entity Alpha must be retrievable after sync");
1200 assert_eq!(alpha.name, "Alpha");
1201 assert_eq!(alpha.kind, "concept");
1202 }
1203
1204 #[tokio::test]
1207 async fn sync_chunk_boundary_round_trip() {
1208 const N: usize = 11; let tmp = TempDir::new().unwrap();
1211 let repo = tmp.path();
1212 let db_path = repo.join(".khive/state/working.db");
1213
1214 let ids: Vec<uuid::Uuid> = (0..N).map(|_| uuid::Uuid::new_v4()).collect();
1216 let mut entity_lines: Vec<(uuid::Uuid, String)> = ids
1221 .iter()
1222 .enumerate()
1223 .map(|(i, id)| {
1224 (
1225 *id,
1226 format!(
1227 r#"{{"id":"{id}","kind":"concept","name":"SyntheticEntity{i}","description":"Synthetic test entity {i} for chunk-boundary coverage","properties":{{}},"tags":["bench","synthetic"]}}"#
1228 ),
1229 )
1230 })
1231 .collect();
1232 entity_lines.sort_by(|a, b| {
1233 a.0.to_string()
1234 .to_ascii_lowercase()
1235 .cmp(&b.0.to_string().to_ascii_lowercase())
1236 });
1237 let entities_ndjson = entity_lines
1238 .into_iter()
1239 .map(|(_, line)| line)
1240 .collect::<Vec<_>>()
1241 .join("\n");
1242
1243 let mut edge_lines: Vec<((String, String, String), String)> = ids
1248 .iter()
1249 .enumerate()
1250 .map(|(i, &src)| {
1251 let tgt = ids[(i + 1) % N];
1252 let eid = uuid::Uuid::new_v4();
1253 let key = (src.to_string(), tgt.to_string(), "extends".to_string());
1254 let line = format!(
1255 r#"{{"edge_id":"{eid}","source":"{src}","target":"{tgt}","relation":"extends","weight":0.9,"properties":{{}}}}"#
1256 );
1257 (key, line)
1258 })
1259 .collect();
1260 edge_lines.sort_by(|a, b| a.0.cmp(&b.0));
1261 let edges_ndjson = edge_lines
1262 .into_iter()
1263 .map(|(_, line)| line)
1264 .collect::<Vec<_>>()
1265 .join("\n");
1266
1267 write_repo(repo, &entities_ndjson, &edges_ndjson);
1268
1269 let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
1270 assert_eq!(report.entities, N, "all {N} entities must be written");
1271 assert_eq!(report.edges, N, "all {N} edges must be written");
1272
1273 let last_id = *ids.last().unwrap();
1275 let ns = khive_types::Namespace::parse("test-ns").unwrap();
1276 let config = RuntimeConfig {
1277 db_path: Some(db_path.clone()),
1278 default_namespace: ns.clone(),
1279 embedding_model: None,
1280 ..RuntimeConfig::default()
1281 };
1282 let rt = KhiveRuntime::new(config).unwrap();
1283 let token = rt.authorize(ns).unwrap();
1284 let last_entity = rt
1285 .entities(&token)
1286 .unwrap()
1287 .get_entity(last_id)
1288 .await
1289 .unwrap()
1290 .expect("last entity (final chunk) must be readable after sync");
1291 assert_eq!(
1292 last_entity.name,
1293 format!("SyntheticEntity{}", N - 1),
1294 "last entity name must match"
1295 );
1296 }
1297
1298 #[tokio::test]
1302 async fn sync_aborts_on_invalid_ndjson_before_db_write() {
1303 let tmp = TempDir::new().unwrap();
1304 let repo = tmp.path();
1305 let db_path = repo.join(".khive/state/working.db");
1306 std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
1307 std::fs::write(&db_path, b"ORIGINAL").unwrap();
1308
1309 let id_a = uuid::Uuid::new_v4();
1312 let good_line = format!(
1313 r#"{{"id":"{id_a}","kind":"concept","name":"Good","properties":{{}},"tags":[]}}"#
1314 );
1315 let bad_ndjson = format!("{good_line}\nnot-valid-json\n");
1316 write_repo(repo, &bad_ndjson, "");
1317
1318 let err = run_sync(repo, &db_path, "test-ns")
1319 .await
1320 .expect_err("sync must fail on invalid NDJSON");
1321 assert!(
1322 err.to_string().contains("parsing entity")
1323 || err.chain().any(|e| e.to_string().contains("expected")),
1324 "error must describe the parse failure, got: {err}"
1325 );
1326
1327 let after = std::fs::read(&db_path).unwrap();
1329 assert_eq!(
1330 after, b"ORIGINAL",
1331 "failed sync must not replace existing DB"
1332 );
1333 }
1334
1335 #[tokio::test]
1336 async fn sync_is_atomic_via_tmp_rename() {
1337 let tmp = TempDir::new().unwrap();
1338 let repo = tmp.path();
1339 let db_path = repo.join(".khive/state/working.db");
1340 std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
1341 std::fs::write(&db_path, b"SENTINEL").unwrap();
1342
1343 write_repo(repo, "not json\n", "");
1344 let err = run_sync(repo, &db_path, "test-ns").await.unwrap_err();
1345 assert!(
1346 err.to_string().to_lowercase().contains("parsing entity")
1347 || err.chain().any(|e| e.to_string().contains("expected")),
1348 "expected parse error, got: {err}"
1349 );
1350
1351 let after = std::fs::read(&db_path).unwrap();
1352 assert_eq!(
1353 after, b"SENTINEL",
1354 "atomic guarantee: failed sync must not replace existing DB"
1355 );
1356 }
1357
1358 #[tokio::test]
1359 async fn sync_missing_ndjson_files_succeeds_with_zero_counts() {
1360 let tmp = TempDir::new().unwrap();
1361 let repo = tmp.path();
1362 let db_path = repo.join(".khive/state/working.db");
1363
1364 let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
1365 assert_eq!(report.entities, 0);
1366 assert_eq!(report.edges, 0);
1367 }
1368
1369 async fn assert_sync_rejected_before_db_write(
1378 repo: &Path,
1379 db_path: &Path,
1380 entities_ndjson: &str,
1381 edges_ndjson: &str,
1382 expected_substr: &str,
1383 ) {
1384 std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
1385 std::fs::write(db_path, b"SENTINEL").unwrap();
1386 write_repo(repo, entities_ndjson, edges_ndjson);
1387
1388 let err = run_sync(repo, db_path, "test-ns")
1389 .await
1390 .expect_err("run_sync must reject invalid NDJSON");
1391 assert!(
1392 err.chain().any(|e| e.to_string().contains(expected_substr)),
1393 "error must mention {expected_substr:?}, got: {err:#}"
1394 );
1395
1396 let after = std::fs::read(db_path).unwrap();
1397 assert_eq!(
1398 after, b"SENTINEL",
1399 "rejected sync must leave the target DB completely untouched"
1400 );
1401 }
1402
1403 #[tokio::test]
1404 async fn sync_rejects_unknown_entity_kind_before_db_write() {
1405 let tmp = TempDir::new().unwrap();
1406 let repo = tmp.path();
1407 let db_path = repo.join(".khive/state/working.db");
1408 let id = "11111111-1111-1111-1111-111111111111";
1409 let entities = format!(
1410 r#"{{"id":"{id}","kind":"not-a-real-kind","name":"Bad","properties":{{}},"tags":[]}}"#
1411 );
1412
1413 assert_sync_rejected_before_db_write(repo, &db_path, &entities, "", "unknown kind").await;
1414 }
1415
1416 #[tokio::test]
1417 async fn sync_rejects_out_of_range_edge_weight_before_db_write() {
1418 let tmp = TempDir::new().unwrap();
1419 let repo = tmp.path();
1420 let db_path = repo.join(".khive/state/working.db");
1421 let id_a = "11111111-1111-1111-1111-111111111111";
1422 let id_b = "22222222-2222-2222-2222-222222222222";
1423 let entities = [
1424 format!(r#"{{"id":"{id_a}","kind":"concept","name":"A","properties":{{}},"tags":[]}}"#),
1425 format!(r#"{{"id":"{id_b}","kind":"concept","name":"B","properties":{{}},"tags":[]}}"#),
1426 ]
1427 .join("\n");
1428 let edge_id = "33333333-3333-3333-3333-333333333333";
1429 let edges = format!(
1430 r#"{{"edge_id":"{edge_id}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":1.5,"properties":{{}}}}"#
1431 );
1432
1433 assert_sync_rejected_before_db_write(repo, &db_path, &entities, &edges, "out of range")
1434 .await;
1435 }
1436
1437 #[tokio::test]
1438 async fn sync_rejects_duplicate_entity_ids_before_db_write() {
1439 let tmp = TempDir::new().unwrap();
1440 let repo = tmp.path();
1441 let db_path = repo.join(".khive/state/working.db");
1442 let id = "11111111-1111-1111-1111-111111111111";
1443 let entities = [
1444 format!(r#"{{"id":"{id}","kind":"concept","name":"A","properties":{{}},"tags":[]}}"#),
1445 format!(r#"{{"id":"{id}","kind":"concept","name":"A2","properties":{{}},"tags":[]}}"#),
1446 ]
1447 .join("\n");
1448
1449 assert_sync_rejected_before_db_write(repo, &db_path, &entities, "", "duplicate entity id")
1450 .await;
1451 }
1452
1453 #[tokio::test]
1454 async fn sync_rejects_duplicate_edge_ids_before_db_write() {
1455 let tmp = TempDir::new().unwrap();
1456 let repo = tmp.path();
1457 let db_path = repo.join(".khive/state/working.db");
1458 let id_a = "11111111-1111-1111-1111-111111111111";
1459 let id_b = "22222222-2222-2222-2222-222222222222";
1460 let id_c = "33333333-3333-3333-3333-333333333333";
1461 let entities = [
1462 format!(r#"{{"id":"{id_a}","kind":"concept","name":"A","properties":{{}},"tags":[]}}"#),
1463 format!(r#"{{"id":"{id_b}","kind":"concept","name":"B","properties":{{}},"tags":[]}}"#),
1464 format!(r#"{{"id":"{id_c}","kind":"concept","name":"C","properties":{{}},"tags":[]}}"#),
1465 ]
1466 .join("\n");
1467 let edge_id = "44444444-4444-4444-4444-444444444444";
1468 let edges = [
1469 format!(r#"{{"edge_id":"{edge_id}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":0.5,"properties":{{}}}}"#),
1470 format!(r#"{{"edge_id":"{edge_id}","source":"{id_a}","target":"{id_c}","relation":"extends","weight":0.5,"properties":{{}}}}"#),
1471 ]
1472 .join("\n");
1473
1474 assert_sync_rejected_before_db_write(
1475 repo,
1476 &db_path,
1477 &entities,
1478 &edges,
1479 "duplicate edge id",
1480 )
1481 .await;
1482 }
1483
1484 #[tokio::test]
1485 async fn sync_rejects_duplicate_edge_triples_before_db_write() {
1486 let tmp = TempDir::new().unwrap();
1487 let repo = tmp.path();
1488 let db_path = repo.join(".khive/state/working.db");
1489 let id_a = "11111111-1111-1111-1111-111111111111";
1490 let id_b = "22222222-2222-2222-2222-222222222222";
1491 let entities = [
1492 format!(r#"{{"id":"{id_a}","kind":"concept","name":"A","properties":{{}},"tags":[]}}"#),
1493 format!(r#"{{"id":"{id_b}","kind":"concept","name":"B","properties":{{}},"tags":[]}}"#),
1494 ]
1495 .join("\n");
1496 let edge_id_1 = "33333333-3333-3333-3333-333333333333";
1497 let edge_id_2 = "44444444-4444-4444-4444-444444444444";
1498 let edges = [
1499 format!(r#"{{"edge_id":"{edge_id_1}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":0.5,"properties":{{}}}}"#),
1500 format!(r#"{{"edge_id":"{edge_id_2}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":0.9,"properties":{{}}}}"#),
1501 ]
1502 .join("\n");
1503
1504 assert_sync_rejected_before_db_write(
1505 repo,
1506 &db_path,
1507 &entities,
1508 &edges,
1509 "duplicate edge triple",
1510 )
1511 .await;
1512 }
1513
1514 #[tokio::test]
1515 async fn sync_rejects_dangling_edge_source_before_db_write() {
1516 let tmp = TempDir::new().unwrap();
1517 let repo = tmp.path();
1518 let db_path = repo.join(".khive/state/working.db");
1519 let id_b = "22222222-2222-2222-2222-222222222222";
1520 let missing_source = "99999999-9999-9999-9999-999999999999";
1521 let entities =
1522 format!(r#"{{"id":"{id_b}","kind":"concept","name":"B","properties":{{}},"tags":[]}}"#);
1523 let edge_id = "33333333-3333-3333-3333-333333333333";
1524 let edges = format!(
1525 r#"{{"edge_id":"{edge_id}","source":"{missing_source}","target":"{id_b}","relation":"extends","weight":0.5,"properties":{{}}}}"#
1526 );
1527
1528 assert_sync_rejected_before_db_write(repo, &db_path, &entities, &edges, "dangling source")
1529 .await;
1530 }
1531
1532 #[tokio::test]
1533 async fn sync_rejects_dangling_edge_target_before_db_write() {
1534 let tmp = TempDir::new().unwrap();
1535 let repo = tmp.path();
1536 let db_path = repo.join(".khive/state/working.db");
1537 let id_a = "11111111-1111-1111-1111-111111111111";
1538 let missing_target = "99999999-9999-9999-9999-999999999999";
1539 let entities =
1540 format!(r#"{{"id":"{id_a}","kind":"concept","name":"A","properties":{{}},"tags":[]}}"#);
1541 let edge_id = "33333333-3333-3333-3333-333333333333";
1542 let edges = format!(
1543 r#"{{"edge_id":"{edge_id}","source":"{id_a}","target":"{missing_target}","relation":"extends","weight":0.5,"properties":{{}}}}"#
1544 );
1545
1546 assert_sync_rejected_before_db_write(repo, &db_path, &entities, &edges, "dangling target")
1547 .await;
1548 }
1549
1550 #[tokio::test]
1551 async fn sync_rejects_invalid_entity_timestamp_before_db_write() {
1552 let tmp = TempDir::new().unwrap();
1553 let repo = tmp.path();
1554 let db_path = repo.join(".khive/state/working.db");
1555 let id = "11111111-1111-1111-1111-111111111111";
1556 let entities = format!(
1557 r#"{{"id":"{id}","kind":"concept","name":"A","properties":{{}},"tags":[],"created_at":"not-a-timestamp"}}"#
1558 );
1559
1560 assert_sync_rejected_before_db_write(repo, &db_path, &entities, "", "invalid created_at")
1561 .await;
1562 }
1563
1564 #[tokio::test]
1565 async fn sync_rejects_invalid_edge_timestamp_before_db_write() {
1566 let tmp = TempDir::new().unwrap();
1567 let repo = tmp.path();
1568 let db_path = repo.join(".khive/state/working.db");
1569 let id_a = "11111111-1111-1111-1111-111111111111";
1570 let id_b = "22222222-2222-2222-2222-222222222222";
1571 let entities = [
1572 format!(r#"{{"id":"{id_a}","kind":"concept","name":"A","properties":{{}},"tags":[]}}"#),
1573 format!(r#"{{"id":"{id_b}","kind":"concept","name":"B","properties":{{}},"tags":[]}}"#),
1574 ]
1575 .join("\n");
1576 let edge_id = "33333333-3333-3333-3333-333333333333";
1577 let edges = format!(
1578 r#"{{"edge_id":"{edge_id}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":0.5,"properties":{{}},"updated_at":"not-a-timestamp"}}"#
1579 );
1580
1581 assert_sync_rejected_before_db_write(
1582 repo,
1583 &db_path,
1584 &entities,
1585 &edges,
1586 "invalid updated_at",
1587 )
1588 .await;
1589 }
1590
1591 #[tokio::test]
1592 async fn sync_rejects_unsorted_entities_before_db_write() {
1593 let tmp = TempDir::new().unwrap();
1594 let repo = tmp.path();
1595 let db_path = repo.join(".khive/state/working.db");
1596 let id_hi = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
1599 let id_lo = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
1600 let entities = [
1601 format!(
1602 r#"{{"id":"{id_hi}","kind":"concept","name":"Hi","properties":{{}},"tags":[]}}"#
1603 ),
1604 format!(
1605 r#"{{"id":"{id_lo}","kind":"concept","name":"Lo","properties":{{}},"tags":[]}}"#
1606 ),
1607 ]
1608 .join("\n");
1609
1610 assert_sync_rejected_before_db_write(
1611 repo,
1612 &db_path,
1613 &entities,
1614 "",
1615 "entities.ndjson is not sorted",
1616 )
1617 .await;
1618 }
1619
1620 #[tokio::test]
1621 async fn sync_rejects_unsorted_edges_before_db_write() {
1622 let tmp = TempDir::new().unwrap();
1623 let repo = tmp.path();
1624 let db_path = repo.join(".khive/state/working.db");
1625 let id_a = "11111111-1111-1111-1111-111111111111";
1626 let id_b = "22222222-2222-2222-2222-222222222222";
1627 let id_c = "33333333-3333-3333-3333-333333333333";
1628 let entities = [
1629 format!(r#"{{"id":"{id_a}","kind":"concept","name":"A","properties":{{}},"tags":[]}}"#),
1630 format!(r#"{{"id":"{id_b}","kind":"concept","name":"B","properties":{{}},"tags":[]}}"#),
1631 format!(r#"{{"id":"{id_c}","kind":"concept","name":"C","properties":{{}},"tags":[]}}"#),
1632 ]
1633 .join("\n");
1634 let edge_id_1 = "44444444-4444-4444-4444-444444444444";
1635 let edge_id_2 = "55555555-5555-5555-5555-555555555555";
1636 let edges = [
1639 format!(r#"{{"edge_id":"{edge_id_1}","source":"{id_c}","target":"{id_a}","relation":"extends","weight":0.5,"properties":{{}}}}"#),
1640 format!(r#"{{"edge_id":"{edge_id_2}","source":"{id_a}","target":"{id_b}","relation":"extends","weight":0.5,"properties":{{}}}}"#),
1641 ]
1642 .join("\n");
1643
1644 assert_sync_rejected_before_db_write(
1645 repo,
1646 &db_path,
1647 &entities,
1648 &edges,
1649 "edges.ndjson is not sorted",
1650 )
1651 .await;
1652 }
1653
1654 #[tokio::test]
1657 async fn sync_preserves_entity_type() {
1658 let tmp = TempDir::new().unwrap();
1659 let repo = tmp.path();
1660 let db_path = repo.join(".khive/state/working.db");
1661
1662 let id_a = "44444444-4444-4444-4444-444444444444";
1663 let line_a = format!(
1664 r#"{{"id":"{id_a}","kind":"document","entity_type":"paper","name":"Attention Is All You Need","properties":{{}},"tags":[]}}"#
1665 );
1666 write_repo(repo, &line_a, "");
1667
1668 let report = run_sync(repo, &db_path, "test-ns").await.unwrap();
1669 assert_eq!(report.entities, 1);
1670
1671 let ns = khive_types::Namespace::parse("test-ns").unwrap();
1672 let config = RuntimeConfig {
1673 db_path: Some(db_path.clone()),
1674 default_namespace: ns.clone(),
1675 embedding_model: None,
1676 ..RuntimeConfig::default()
1677 };
1678 let rt = KhiveRuntime::new(config).unwrap();
1679 let token = rt.authorize(ns).unwrap();
1680 let entity = rt
1681 .entities(&token)
1682 .unwrap()
1683 .get_entity(id_a.parse().unwrap())
1684 .await
1685 .unwrap()
1686 .expect("entity must be retrievable after sync");
1687 assert_eq!(
1688 entity.entity_type.as_deref(),
1689 Some("paper"),
1690 "entity_type must survive NDJSON sync, not be stored as NULL"
1691 );
1692 }
1693
1694 #[test]
1698 fn remote_hash_includes_ndjson_entity_type() {
1699 let id_a = "55555555-5555-5555-5555-555555555555";
1700 let untyped = format!(
1701 r#"{{"id":"{id_a}","kind":"document","name":"Some Doc","properties":{{}},"tags":[]}}"#
1702 );
1703 let typed = format!(
1704 r#"{{"id":"{id_a}","kind":"document","entity_type":"paper","name":"Some Doc","properties":{{}},"tags":[]}}"#
1705 );
1706
1707 let untyped_pin = compute_pin(&untyped, "", "test-ns");
1708 let typed_pin = compute_pin(&typed, "", "test-ns");
1709
1710 assert_ne!(
1711 untyped_pin.as_str(),
1712 typed_pin.as_str(),
1713 "entity_type must be part of the canonical hash input; a pin for the \
1714 untyped archive must not validate typed content"
1715 );
1716 }
1717
1718 #[tokio::test]
1721 async fn sync_populates_fts_for_text_search() {
1722 use khive_runtime::RuntimeConfig;
1723 use khive_storage::types::{TextFilter, TextQueryMode, TextSearchRequest};
1724
1725 let tmp = TempDir::new().unwrap();
1726 let repo = tmp.path();
1727 let db_path = repo.join(".khive/state/working.db");
1728
1729 let id_a = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
1730 let line_a = format!(
1731 r#"{{"id":"{id_a}","kind":"concept","name":"FlashAttention","description":"Fast attention algorithm","properties":{{}},"tags":[]}}"#
1732 );
1733 write_repo(repo, &line_a, "");
1734
1735 run_sync(repo, &db_path, "test-ns").await.unwrap();
1736
1737 let ns = khive_types::Namespace::parse("test-ns").unwrap();
1738 let config = RuntimeConfig {
1739 db_path: Some(db_path.clone()),
1740 default_namespace: ns.clone(),
1741 embedding_model: None,
1742 ..RuntimeConfig::default()
1743 };
1744 let rt = KhiveRuntime::new(config).unwrap();
1745 let token = rt.authorize(ns).unwrap();
1746
1747 let hits = rt
1748 .text(&token)
1749 .expect("text store must be available")
1750 .search(TextSearchRequest {
1751 query: "FlashAttention".to_string(),
1752 filter: Some(TextFilter {
1753 namespaces: vec!["test-ns".to_string()],
1754 ..Default::default()
1755 }),
1756 mode: TextQueryMode::Phrase,
1757 top_k: 10,
1758 snippet_chars: 128,
1759 })
1760 .await
1761 .expect("text search must succeed after sync");
1762
1763 assert!(
1764 !hits.is_empty(),
1765 "FTS search for 'FlashAttention' must return results after sync (F195)"
1766 );
1767 assert_eq!(
1768 hits[0].subject_id.to_string(),
1769 id_a,
1770 "FTS hit must reference the synced entity UUID"
1771 );
1772 }
1773
1774 #[test]
1779 fn remote_name_rejects_path_traversal_cases() {
1780 for bad in [
1781 "",
1782 ".",
1783 "..",
1784 "../evil",
1785 "../../outside",
1786 "/tmp/evil",
1787 "safe/name",
1788 "safe\\name",
1789 "safe/../evil",
1790 ] {
1791 assert!(
1792 RemoteName::parse(bad).is_err(),
1793 "expected RemoteName::parse to reject {bad:?}"
1794 );
1795 }
1796 }
1797
1798 #[test]
1800 fn remote_name_accepts_single_safe_segment() {
1801 for good in ["upstream", "team.data-1", "remote_2"] {
1802 let parsed = RemoteName::parse(good).expect("expected safe name to be accepted");
1803 assert_eq!(parsed.as_str(), good);
1804 }
1805 }
1806
1807 #[tokio::test]
1810 async fn run_sync_remote_cannot_be_constructed_with_invalid_name() {
1811 let repo_dir = TempDir::new().unwrap();
1812 let outside_target = repo_dir.path().parent().unwrap().join("evil-outside-probe");
1813 let _ = std::fs::remove_file(&outside_target);
1814
1815 for bad in ["../evil", "/tmp/evil", "safe/name"] {
1816 assert!(
1817 RemoteName::parse(bad).is_err(),
1818 "RemoteName::parse must reject {bad:?} before any RemoteConfig can be built"
1819 );
1820 }
1821
1822 assert!(
1825 !repo_dir.path().join(".khive/kg/remotes").exists(),
1826 "cache tree must not exist — no sync ever ran"
1827 );
1828 assert!(
1829 !std::path::Path::new("/tmp/evil").exists(),
1830 "/tmp/evil must not have been created by this test run"
1831 );
1832 assert!(
1833 !outside_target.exists(),
1834 "nothing must be written outside the repo root"
1835 );
1836 }
1837
1838 fn sample_entity(id: &str, name: &str) -> NdjsonEntity {
1841 NdjsonEntity {
1842 id: id.parse().unwrap(),
1843 kind: "concept".to_string(),
1844 entity_type: None,
1845 name: name.to_string(),
1846 description: None,
1847 properties: Some(serde_json::json!({})),
1848 tags: vec![],
1849 created_at: None,
1850 updated_at: None,
1851 }
1852 }
1853
1854 fn sample_meta(tag: &str) -> MetaJson {
1855 MetaJson {
1856 fetched_at: "2026-01-01T00:00:00Z".to_string(),
1857 git_ref: "main".to_string(),
1858 commit_sha: "deadbeef".to_string(),
1859 content_hash: format!("sha256:{tag}"),
1860 }
1861 }
1862
1863 fn publish_old_generation(remotes_root: &Path, name: &str) -> PathBuf {
1866 let entities = vec![sample_entity(
1867 "11111111-1111-1111-1111-111111111111",
1868 "OldEntity",
1869 )];
1870 publish_remote_cache(
1871 remotes_root,
1872 name,
1873 &entities,
1874 &[],
1875 &sample_meta("old"),
1876 None,
1877 )
1878 .expect("baseline publish must succeed")
1879 }
1880
1881 fn assert_cache_is_old_generation(cache_dir: &Path) {
1882 let entities = std::fs::read_to_string(cache_dir.join("entities.ndjson")).unwrap();
1883 assert!(
1884 entities.contains("OldEntity"),
1885 "cache entities must still be the old generation, got: {entities}"
1886 );
1887 let meta: serde_json::Value =
1888 serde_json::from_str(&std::fs::read_to_string(cache_dir.join("meta.json")).unwrap())
1889 .unwrap();
1890 assert_eq!(meta["content_hash"], "sha256:old");
1891 }
1892
1893 #[test]
1894 fn remote_cache_publish_failure_after_entities_keeps_old_cache() {
1895 let tmp = TempDir::new().unwrap();
1896 let remotes_root = tmp.path().join(".khive/kg/remotes");
1897 let cache_dir = publish_old_generation(&remotes_root, "upstream");
1898
1899 let new_entities = vec![sample_entity(
1900 "22222222-2222-2222-2222-222222222222",
1901 "NewEntity",
1902 )];
1903 let err = publish_remote_cache(
1904 &remotes_root,
1905 "upstream",
1906 &new_entities,
1907 &[],
1908 &sample_meta("new"),
1909 Some(PublishFailAt::AfterEntities),
1910 )
1911 .expect_err("injected failure must surface as an error");
1912 assert!(err.to_string().contains("injected failure"));
1913
1914 assert_cache_is_old_generation(&cache_dir);
1915 }
1916
1917 #[test]
1918 fn remote_cache_publish_failure_after_edges_keeps_old_cache() {
1919 let tmp = TempDir::new().unwrap();
1920 let remotes_root = tmp.path().join(".khive/kg/remotes");
1921 let cache_dir = publish_old_generation(&remotes_root, "upstream");
1922
1923 let new_entities = vec![sample_entity(
1924 "22222222-2222-2222-2222-222222222222",
1925 "NewEntity",
1926 )];
1927 let err = publish_remote_cache(
1928 &remotes_root,
1929 "upstream",
1930 &new_entities,
1931 &[],
1932 &sample_meta("new"),
1933 Some(PublishFailAt::AfterEdges),
1934 )
1935 .expect_err("injected failure must surface as an error");
1936 assert!(err.to_string().contains("injected failure"));
1937
1938 assert_cache_is_old_generation(&cache_dir);
1939 }
1940
1941 #[test]
1942 fn remote_cache_publish_failure_after_meta_keeps_old_cache() {
1943 let tmp = TempDir::new().unwrap();
1944 let remotes_root = tmp.path().join(".khive/kg/remotes");
1945 let cache_dir = publish_old_generation(&remotes_root, "upstream");
1946
1947 let new_entities = vec![sample_entity(
1948 "22222222-2222-2222-2222-222222222222",
1949 "NewEntity",
1950 )];
1951 let err = publish_remote_cache(
1952 &remotes_root,
1953 "upstream",
1954 &new_entities,
1955 &[],
1956 &sample_meta("new"),
1957 Some(PublishFailAt::AfterMeta),
1958 )
1959 .expect_err("injected failure must surface as an error");
1960 assert!(err.to_string().contains("injected failure"));
1961
1962 assert_cache_is_old_generation(&cache_dir);
1963 }
1964
1965 #[test]
1969 fn remote_cache_publish_failure_before_swap_keeps_old_cache() {
1970 let tmp = TempDir::new().unwrap();
1971 let remotes_root = tmp.path().join(".khive/kg/remotes");
1972 let cache_dir = publish_old_generation(&remotes_root, "upstream");
1973
1974 let new_entities = vec![sample_entity(
1975 "22222222-2222-2222-2222-222222222222",
1976 "NewEntity",
1977 )];
1978 let err = publish_remote_cache(
1979 &remotes_root,
1980 "upstream",
1981 &new_entities,
1982 &[],
1983 &sample_meta("new"),
1984 Some(PublishFailAt::BeforeSwap),
1985 )
1986 .expect_err("injected failure must surface as an error");
1987 assert!(err.to_string().contains("injected failure"));
1988
1989 assert_cache_is_old_generation(&cache_dir);
1990 }
1991
1992 #[test]
1995 fn remote_cache_publish_success_exposes_complete_new_cache() {
1996 let tmp = TempDir::new().unwrap();
1997 let remotes_root = tmp.path().join(".khive/kg/remotes");
1998 let cache_dir = publish_old_generation(&remotes_root, "upstream");
1999 assert_cache_is_old_generation(&cache_dir);
2000
2001 let new_entities = vec![sample_entity(
2002 "22222222-2222-2222-2222-222222222222",
2003 "NewEntity",
2004 )];
2005 let published = publish_remote_cache(
2006 &remotes_root,
2007 "upstream",
2008 &new_entities,
2009 &[],
2010 &sample_meta("new"),
2011 None,
2012 )
2013 .expect("publish must succeed");
2014 assert_eq!(published, cache_dir);
2015
2016 let entities = std::fs::read_to_string(cache_dir.join("entities.ndjson")).unwrap();
2017 assert!(entities.contains("NewEntity"));
2018 assert!(
2019 !entities.contains("OldEntity"),
2020 "old generation entity must not linger after a successful publish"
2021 );
2022 let meta: serde_json::Value =
2023 serde_json::from_str(&std::fs::read_to_string(cache_dir.join("meta.json")).unwrap())
2024 .unwrap();
2025 assert_eq!(meta["content_hash"], "sha256:new");
2026 }
2027
2028 #[tokio::test]
2033 async fn run_sync_remote_fetches_and_verifies_hash_match() {
2034 let remote_dir = TempDir::new().unwrap();
2035 let repo_dir = TempDir::new().unwrap();
2036
2037 let id_a = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
2038 let entities = format!(
2039 r#"{{"id":"{id_a}","kind":"concept","name":"RemoteEntity","properties":{{}},"tags":[]}}"#
2040 );
2041 let edges = "";
2042
2043 let remote_url = make_git_remote(remote_dir.path(), &entities, edges);
2044 let expected_pin = compute_pin(&entities, edges, "remote-ns");
2045
2046 let remote = RemoteConfig {
2047 name: RemoteName::parse("upstream").unwrap(),
2048 url: remote_url,
2049 git_ref: "main".to_string(),
2050 namespace: "remote-ns".to_string(),
2051 pin: Some(expected_pin.clone()),
2052 };
2053
2054 let report = run_sync_remote(repo_dir.path(), &remote, false)
2055 .await
2056 .expect("run_sync_remote must succeed with correct pin");
2057
2058 assert_eq!(report.entities, 1, "must report 1 entity");
2059 assert_eq!(report.edges, 0, "must report 0 edges");
2060 assert_eq!(
2061 report.content_hash,
2062 expected_pin.as_str(),
2063 "content_hash must match the pin"
2064 );
2065 assert!(!report.repinned, "repin was not requested");
2066
2067 let cache = repo_dir.path().join(".khive/kg/remotes/upstream");
2069 assert!(
2070 cache.join("entities.ndjson").exists(),
2071 "entities.ndjson must exist in cache"
2072 );
2073 assert!(
2074 cache.join("edges.ndjson").exists(),
2075 "edges.ndjson must exist in cache"
2076 );
2077 assert!(
2078 cache.join("meta.json").exists(),
2079 "meta.json must exist in cache"
2080 );
2081
2082 let meta_bytes = std::fs::read(cache.join("meta.json")).unwrap();
2084 let meta: serde_json::Value = serde_json::from_slice(&meta_bytes).unwrap();
2085 assert_eq!(
2086 meta["content_hash"].as_str().unwrap(),
2087 expected_pin.as_str(),
2088 "meta.json content_hash must match"
2089 );
2090 assert!(
2091 meta["fetched_at"].as_str().is_some(),
2092 "meta.json must have fetched_at"
2093 );
2094 assert!(
2095 meta["commit_sha"].as_str().is_some(),
2096 "meta.json must have commit_sha"
2097 );
2098 }
2099
2100 #[tokio::test]
2103 async fn run_sync_remote_rejects_hash_mismatch() {
2104 let remote_dir = TempDir::new().unwrap();
2105 let repo_dir = TempDir::new().unwrap();
2106
2107 let id_b = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
2108 let entities = format!(
2109 r#"{{"id":"{id_b}","kind":"concept","name":"AnotherEntity","properties":{{}},"tags":[]}}"#
2110 );
2111 let edges = "";
2112
2113 let remote_url = make_git_remote(remote_dir.path(), &entities, edges);
2114
2115 let wrong_pin = SnapshotId::from_hash(&"0".repeat(64)).unwrap();
2117
2118 let remote = RemoteConfig {
2119 name: RemoteName::parse("upstream").unwrap(),
2120 url: remote_url,
2121 git_ref: "main".to_string(),
2122 namespace: "remote-ns".to_string(),
2123 pin: Some(wrong_pin.clone()),
2124 };
2125
2126 let err = run_sync_remote(repo_dir.path(), &remote, false)
2127 .await
2128 .expect_err("run_sync_remote must fail on hash mismatch");
2129
2130 let err_msg = err.to_string();
2131 assert!(
2132 err_msg.contains("hash mismatch") || err_msg.contains("sha256:"),
2133 "error must mention hash mismatch, got: {err_msg}"
2134 );
2135
2136 let cache = repo_dir.path().join(".khive/kg/remotes/upstream");
2138 assert!(
2139 !cache.join("entities.ndjson").exists(),
2140 "entities.ndjson must NOT exist after mismatch"
2141 );
2142 assert!(
2143 !cache.join("meta.json").exists(),
2144 "meta.json must NOT exist after mismatch"
2145 );
2146 }
2147
2148 #[tokio::test]
2151 async fn run_sync_remote_no_pin_proceeds_and_writes_meta() {
2152 let remote_dir = TempDir::new().unwrap();
2153 let repo_dir = TempDir::new().unwrap();
2154
2155 let id_c = "cccccccc-cccc-cccc-cccc-cccccccccccc";
2156 let entities = format!(
2157 r#"{{"id":"{id_c}","kind":"concept","name":"Pinless","properties":{{}},"tags":[]}}"#
2158 );
2159
2160 let remote_url = make_git_remote(remote_dir.path(), &entities, "");
2161
2162 let remote = RemoteConfig {
2163 name: RemoteName::parse("no-pin-remote").unwrap(),
2164 url: remote_url,
2165 git_ref: "main".to_string(),
2166 namespace: "remote-ns".to_string(),
2167 pin: None,
2168 };
2169
2170 let report = run_sync_remote(repo_dir.path(), &remote, false)
2171 .await
2172 .expect("run_sync_remote must succeed with no pin");
2173
2174 assert_eq!(report.entities, 1);
2175 assert!(
2176 report.content_hash.starts_with("sha256:"),
2177 "content_hash must have sha256: prefix even without pin"
2178 );
2179
2180 let cache = repo_dir.path().join(".khive/kg/remotes/no-pin-remote");
2181 assert!(
2182 cache.join("meta.json").exists(),
2183 "meta.json must be written even when pin is absent"
2184 );
2185 }
2186
2187 #[tokio::test]
2190 async fn run_sync_remote_repin_updates_hash_ignoring_old_pin() {
2191 let remote_dir = TempDir::new().unwrap();
2192 let repo_dir = TempDir::new().unwrap();
2193
2194 let id_d = "dddddddd-dddd-dddd-dddd-dddddddddddd";
2195 let entities = format!(
2196 r#"{{"id":"{id_d}","kind":"concept","name":"RepinTarget","properties":{{}},"tags":[]}}"#
2197 );
2198
2199 let remote_url = make_git_remote(remote_dir.path(), &entities, "");
2200 let actual_hash = compute_pin(&entities, "", "repin-ns");
2201
2202 let stale_pin = SnapshotId::from_hash(&"f".repeat(64)).unwrap();
2204
2205 let remote = RemoteConfig {
2206 name: RemoteName::parse("repinned").unwrap(),
2207 url: remote_url,
2208 git_ref: "main".to_string(),
2209 namespace: "repin-ns".to_string(),
2210 pin: Some(stale_pin),
2211 };
2212
2213 let report = run_sync_remote(repo_dir.path(), &remote, true)
2214 .await
2215 .expect("repin must succeed even with wrong existing pin");
2216
2217 assert!(report.repinned, "repinned flag must be true");
2218 assert_eq!(
2219 report.content_hash,
2220 actual_hash.as_str(),
2221 "repinned hash must be the actual fetched archive hash"
2222 );
2223
2224 let cache = repo_dir.path().join(".khive/kg/remotes/repinned");
2226 assert!(cache.join("entities.ndjson").exists());
2227 assert!(cache.join("meta.json").exists());
2228 }
2229
2230 #[test]
2234 fn redact_strips_credential_url() {
2235 let raw = "fatal: Authentication failed for 'https://user:token@host/repo.git'";
2236 let out = redact_git_stderr(raw);
2237 assert!(
2238 !out.contains("user:token"),
2239 "credential must be redacted, got: {out}"
2240 );
2241 assert!(
2242 !out.contains("host/repo.git"),
2243 "host/path must be redacted, got: {out}"
2244 );
2245 assert!(
2246 out.contains("<url-redacted>"),
2247 "placeholder must be present, got: {out}"
2248 );
2249 }
2250
2251 #[test]
2253 fn redact_passes_plain_text() {
2254 let raw = "error: unable to read refs from remote";
2255 assert_eq!(redact_git_stderr(raw), raw);
2256 }
2257
2258 #[test]
2260 fn redact_handles_multiple_urls() {
2261 let raw = "fetch https://a:b@host1/r1.git and push https://c:d@host2/r2.git failed";
2262 let out = redact_git_stderr(raw);
2263 assert!(!out.contains("a:b"), "first credential must be redacted");
2264 assert!(!out.contains("c:d"), "second credential must be redacted");
2265 assert_eq!(
2266 out.matches("<url-redacted>").count(),
2267 2,
2268 "both URLs must be replaced"
2269 );
2270 }
2271
2272 #[test]
2274 fn redact_handles_url_without_credentials() {
2275 let raw = "fatal: repository 'https://github.com/org/private-repo.git/' not found";
2276 let out = redact_git_stderr(raw);
2277 assert!(
2278 !out.contains("github.com/org/private-repo"),
2279 "URL path must be redacted"
2280 );
2281 assert!(
2282 out.contains("<url-redacted>"),
2283 "placeholder must be present"
2284 );
2285 }
2286
2287 #[test]
2289 fn redact_strips_scp_style_remote() {
2290 let raw = "ERROR: Repository not found.\nfatal: Could not read from remote repository git@github.com:org/private-repo.git";
2291 let out = redact_git_stderr(raw);
2292 assert!(
2293 !out.contains("git@"),
2294 "scp userinfo must be redacted, got: {out}"
2295 );
2296 assert!(
2297 !out.contains("github.com"),
2298 "scp host must be redacted, got: {out}"
2299 );
2300 assert!(
2301 !out.contains("private-repo"),
2302 "scp path must be redacted, got: {out}"
2303 );
2304 assert!(
2305 out.contains("<url-redacted>"),
2306 "placeholder must be present, got: {out}"
2307 );
2308 }
2309
2310 #[test]
2312 fn redact_strips_user_at_host_colon_path() {
2313 let raw = "fatal: repository user@bitbucket.org:myteam/myrepo.git not found";
2314 let out = redact_git_stderr(raw);
2315 assert!(
2316 !out.contains("user@"),
2317 "userinfo must be redacted, got: {out}"
2318 );
2319 assert!(
2320 !out.contains("bitbucket.org"),
2321 "host must be redacted, got: {out}"
2322 );
2323 assert!(
2324 out.contains("<url-redacted>"),
2325 "placeholder must be present, got: {out}"
2326 );
2327 }
2328
2329 #[test]
2332 fn redact_does_not_over_redact_plain_colon() {
2333 let raw = "error: src refspec main does not match any";
2334 let out = redact_git_stderr(raw);
2335 assert_eq!(out, raw, "plain text with colon must not be altered");
2336 }
2337
2338 #[tokio::test]
2354 async fn public_error_redacts_https_credential_url() {
2355 let repo_dir = tempfile::TempDir::new().unwrap();
2356 let remote = RemoteConfig {
2358 name: RemoteName::parse("cred-test").unwrap(),
2359 url: "https://user:secret_token@nonexistent.example.invalid/org/repo.git".to_string(),
2360 git_ref: "main".to_string(),
2361 namespace: "test-ns".to_string(),
2362 pin: None,
2363 };
2364 let err = run_sync_remote(repo_dir.path(), &remote, false)
2365 .await
2366 .expect_err("clone of nonexistent URL must fail");
2367
2368 let err_str = err.to_string();
2369 let err_chain: String = err
2370 .chain()
2371 .map(|e| e.to_string())
2372 .collect::<Vec<_>>()
2373 .join(" | ");
2374
2375 assert!(
2376 !err_str.contains("secret_token") && !err_chain.contains("secret_token"),
2377 "credential must not appear in public error string or chain: {err_str} | {err_chain}"
2378 );
2379 assert!(
2380 !err_str.contains("user:") && !err_chain.contains("user:"),
2381 "userinfo must not appear in public error: {err_str} | {err_chain}"
2382 );
2383 assert!(
2385 err_str.contains("cred-test") || err_chain.contains("cred-test"),
2386 "remote name must be present for diagnostics: {err_str} | {err_chain}"
2387 );
2388 }
2389
2390 #[tokio::test]
2393 async fn public_error_redacts_scp_style_remote() {
2394 let repo_dir = tempfile::TempDir::new().unwrap();
2395 let remote = RemoteConfig {
2396 name: RemoteName::parse("scp-test").unwrap(),
2397 url: "git@nonexistent.example.invalid:org/private-repo.git".to_string(),
2398 git_ref: "main".to_string(),
2399 namespace: "test-ns".to_string(),
2400 pin: None,
2401 };
2402 let err = run_sync_remote(repo_dir.path(), &remote, false)
2403 .await
2404 .expect_err("clone of nonexistent scp remote must fail");
2405
2406 let err_str = err.to_string();
2407 let err_chain: String = err
2408 .chain()
2409 .map(|e| e.to_string())
2410 .collect::<Vec<_>>()
2411 .join(" | ");
2412
2413 assert!(
2418 !err_str.contains("git@nonexistent.example.invalid")
2419 && !err_chain.contains("git@nonexistent.example.invalid"),
2420 "scp remote URL must not appear in public error: {err_str} | {err_chain}"
2421 );
2422 assert!(
2423 !err_str.contains("private-repo") && !err_chain.contains("private-repo"),
2424 "scp repo path must not appear in public error: {err_str} | {err_chain}"
2425 );
2426 assert!(
2428 err_str.contains("scp-test") || err_chain.contains("scp-test"),
2429 "remote name must be present for diagnostics: {err_str} | {err_chain}"
2430 );
2431 }
2432
2433 #[test]
2437 fn sync_fts_document_matches_entity_fts_document() {
2438 use khive_runtime::entity_fts_document;
2439 use khive_storage::SubstrateKind;
2440
2441 let id = uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap();
2442 let props: Option<serde_json::Value> =
2443 Some(serde_json::json!({"domain": "attention", "status": "researched"}));
2444
2445 let entity = khive_storage::entity::Entity {
2446 id,
2447 namespace: "test-ns".to_string(),
2448 kind: "concept".to_string(),
2449 entity_type: None,
2450 name: "FlashAttention".to_string(),
2451 description: Some("Fast attention algorithm".to_string()),
2452 properties: props.clone(),
2453 tags: vec!["attention".to_string(), "inference".to_string()],
2454 created_at: 1_000_000,
2455 updated_at: 2_000_000,
2456 deleted_at: None,
2457 merge_event_id: None,
2458 merged_into: None,
2459 content_ref: None,
2460 };
2461
2462 let doc = entity_fts_document(&entity);
2463
2464 assert_eq!(doc.subject_id, id);
2465 assert_eq!(doc.kind, SubstrateKind::Entity);
2466 assert_eq!(doc.namespace, "test-ns");
2467 assert_eq!(doc.title.as_deref(), Some("FlashAttention"));
2468 assert_eq!(doc.body, "FlashAttention Fast attention algorithm");
2469 assert_eq!(
2470 doc.tags,
2471 vec!["attention".to_string(), "inference".to_string()]
2472 );
2473 assert_eq!(doc.metadata, props);
2474 assert_eq!(
2475 doc.updated_at,
2476 chrono::DateTime::from_timestamp_micros(2_000_000).unwrap()
2477 );
2478 }
2479
2480 #[tokio::test]
2482 async fn public_error_redacts_user_pass_at_host_scp() {
2483 let repo_dir = tempfile::TempDir::new().unwrap();
2484 let remote = RemoteConfig {
2485 name: RemoteName::parse("userpass-scp").unwrap(),
2486 url: "deploy@nonexistent.example.invalid:infra/secret-repo.git".to_string(),
2487 git_ref: "main".to_string(),
2488 namespace: "test-ns".to_string(),
2489 pin: None,
2490 };
2491 let err = run_sync_remote(repo_dir.path(), &remote, false)
2492 .await
2493 .expect_err("clone of nonexistent scp remote must fail");
2494
2495 let err_str = err.to_string();
2496 let err_chain: String = err
2497 .chain()
2498 .map(|e| e.to_string())
2499 .collect::<Vec<_>>()
2500 .join(" | ");
2501
2502 assert!(
2503 !err_str.contains("deploy@nonexistent.example.invalid")
2504 && !err_chain.contains("deploy@nonexistent.example.invalid"),
2505 "scp userinfo+host must not appear in public error: {err_str} | {err_chain}"
2506 );
2507 assert!(
2508 !err_str.contains("secret-repo") && !err_chain.contains("secret-repo"),
2509 "scp repo path must not appear in public error: {err_str} | {err_chain}"
2510 );
2511 }
2512}
2513
2514#[cfg(test)]
2536mod checkpoint_wal_write_queue_tests {
2537 #[tokio::test]
2538 async fn wal_checkpoint_truncate_succeeds_with_write_queue_enabled() {
2539 let dir = tempfile::tempdir().expect("tempdir");
2540 let db_path = dir.path().join("vcs-checkpoint-write-queue.db");
2541 let pool_cfg = khive_db::PoolConfig {
2542 path: Some(db_path),
2543 write_queue_enabled: true,
2544 ..khive_db::PoolConfig::default()
2545 };
2546 let pool = std::sync::Arc::new(khive_db::ConnectionPool::new(pool_cfg).expect("pool"));
2547 {
2548 let mut writer = pool.writer().expect("writer");
2549 khive_db::run_migrations(writer.conn_mut()).expect("migrations");
2550 }
2551 assert!(
2552 pool.writer_task_handle().unwrap().is_some(),
2553 "writer task must be spawned with the flag on for a file-backed pool"
2554 );
2555
2556 let sql: std::sync::Arc<dyn khive_storage::SqlAccess> =
2557 std::sync::Arc::new(khive_db::SqlBridge::new(std::sync::Arc::clone(&pool), true));
2558
2559 let mut writer = sql.writer().await.expect("writer handle");
2560 let result = writer
2561 .execute_script_top_level("PRAGMA wal_checkpoint(TRUNCATE);".to_string())
2562 .await;
2563
2564 assert!(
2565 result.is_ok(),
2566 "PRAGMA wal_checkpoint(TRUNCATE) via execute_script_top_level must succeed under \
2567 KHIVE_WRITE_QUEUE (no BEGIN IMMEDIATE wrap); got {result:?}"
2568 );
2569 }
2570
2571 #[tokio::test]
2574 async fn wal_checkpoint_truncate_via_plain_execute_script_fails_with_write_queue_enabled() {
2575 let dir = tempfile::tempdir().expect("tempdir");
2576 let db_path = dir.path().join("vcs-checkpoint-write-queue-regression.db");
2577 let pool_cfg = khive_db::PoolConfig {
2578 path: Some(db_path),
2579 write_queue_enabled: true,
2580 ..khive_db::PoolConfig::default()
2581 };
2582 let pool = std::sync::Arc::new(khive_db::ConnectionPool::new(pool_cfg).expect("pool"));
2583 {
2584 let mut writer = pool.writer().expect("writer");
2585 khive_db::run_migrations(writer.conn_mut()).expect("migrations");
2586 }
2587
2588 let sql: std::sync::Arc<dyn khive_storage::SqlAccess> =
2589 std::sync::Arc::new(khive_db::SqlBridge::new(std::sync::Arc::clone(&pool), true));
2590
2591 let mut writer = sql.writer().await.expect("writer handle");
2592 let result = writer
2593 .execute_script("PRAGMA wal_checkpoint(TRUNCATE);".to_string())
2594 .await;
2595
2596 assert!(
2597 result.is_err(),
2598 "PRAGMA wal_checkpoint(TRUNCATE) via plain execute_script must FAIL under \
2599 KHIVE_WRITE_QUEUE (it wraps in BEGIN IMMEDIATE, and SQLite rejects a WAL \
2600 checkpoint inside an open transaction); got {result:?} — if this now passes, \
2601 the WriterTask no longer wraps execute_script in a transaction and this whole \
2602 regression class needs re-auditing"
2603 );
2604 }
2605}