Skip to main content

code_system_graph/
sync.rs

1//! Incremental synchronization orchestration for local workspace indexes.
2
3use std::ffi::{OsStr, OsString};
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::time::{Duration, Instant};
7
8use code_system_graph_core::{ConfigSource, EffectiveRepositoryConfig, IgnorePolicy};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use super::{
13    ApplicationError, ScanOverrides, ScanSummary, load_workspace_context, work_database_instance_id
14};
15
16const MAX_PERSISTED_WATCH_TARGET_BYTES: u64 = 8 * 1024 * 1024;
17
18/// One registered repository that participates in synchronization.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct SyncTarget {
21    /// Repository alias from the workspace manifest.
22    pub alias: String,
23    /// Canonical local checkout path.
24    pub path: PathBuf,
25    /// Effective native discovery policy for this checkout.
26    pub ignore_policy: IgnorePolicy,
27    /// Explicit repository-relative artifacts that must remain observable through exclusions.
28    pub explicit_paths: Vec<PathBuf>,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
32struct PersistedWatchTarget {
33    alias: String,
34    path: PathBuf,
35    configured_excludes: Vec<String>,
36    configured_excludes_source: ConfigSource,
37    include_defaults: Vec<String>,
38    include_defaults_source: ConfigSource,
39    explicit_paths: Vec<PathBuf>,
40}
41
42impl From<&SyncTarget> for PersistedWatchTarget {
43    fn from(target: &SyncTarget) -> Self {
44        Self {
45            alias: target.alias.clone(),
46            path: target.path.clone(),
47            configured_excludes: target.ignore_policy.configured_excludes().to_vec(),
48            configured_excludes_source: target.ignore_policy.configured_excludes_source(),
49            include_defaults: target.ignore_policy.include_defaults().to_vec(),
50            include_defaults_source: target.ignore_policy.include_defaults_source(),
51            explicit_paths: target.explicit_paths.clone(),
52        }
53    }
54}
55
56impl TryFrom<PersistedWatchTarget> for SyncTarget {
57    type Error = ApplicationError;
58
59    fn try_from(target: PersistedWatchTarget) -> Result<Self, Self::Error> {
60        let ignore_policy = IgnorePolicy::new(
61            target.configured_excludes,
62            target.configured_excludes_source,
63            target.include_defaults,
64            target.include_defaults_source,
65        )
66        .map_err(|error| {
67            ApplicationError::Initialization(format!("invalid persisted watch scope: {error}"))
68        })?;
69        Ok(Self {
70            alias: target.alias,
71            path: target.path,
72            ignore_policy,
73            explicit_paths: target.explicit_paths,
74        })
75    }
76}
77
78/// Outcome of synchronizing one repository's local `CodeGraph` index.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
80#[serde(rename_all = "snake_case")]
81pub enum CodeGraphSyncState {
82    /// `codegraph sync` completed successfully.
83    Synchronized,
84    /// The repository has no initialized `.codegraph` index.
85    SkippedNotInitialized,
86    /// The external synchronization command failed.
87    Failed,
88}
89
90/// Bounded result for one repository's local `CodeGraph` index.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
92pub struct CodeGraphRepositorySync {
93    /// Repository alias from the workspace manifest.
94    pub repository: String,
95    /// Synchronization outcome.
96    pub state: CodeGraphSyncState,
97    /// Bounded diagnostic when the index was skipped or failed.
98    pub detail: Option<String>,
99}
100
101/// Aggregate `CodeGraph` synchronization result.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
103pub struct CodeGraphSyncSummary {
104    /// Whether local `CodeGraph` synchronization was requested.
105    pub enabled: bool,
106    /// Number of selected workspace repositories.
107    pub repository_count: usize,
108    /// Number of local indexes synchronized successfully.
109    pub synchronized_count: usize,
110    /// Number of repositories without an initialized local index.
111    pub skipped_count: usize,
112    /// Number of external synchronization failures.
113    pub failed_count: usize,
114    /// Deterministic per-repository outcomes.
115    pub repositories: Vec<CodeGraphRepositorySync>,
116}
117
118/// Observable result of one `csgraph sync` pass.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
120pub struct SyncSummary {
121    /// Output schema version.
122    pub schema_version: u8,
123    /// Resource accounting for the complete supervised sync pass.
124    pub execution: code_system_graph_core::ExecutionSummary,
125    /// Incremental Code System Graph scan result.
126    pub scan: ScanSummary,
127    /// Local per-repository `CodeGraph` index results.
128    pub codegraph: CodeGraphSyncSummary,
129}
130
131/// Resolves and validates the repository checkouts selected for synchronization.
132///
133/// # Errors
134///
135/// Returns [`ApplicationError`] when the manifest, repository selection, or checkout registry is
136/// invalid.
137pub fn workspace_sync_targets(
138    config_path: &Path,
139    overrides: &ScanOverrides,
140) -> Result<Vec<SyncTarget>, ApplicationError> {
141    let context = load_workspace_context(config_path, overrides)?;
142    if let Some(requested) = &overrides.workspace
143        && requested != &context.manifest.name
144    {
145        return Err(ApplicationError::WorkspaceNameMismatch {
146            requested: requested.clone(),
147            manifest: context.manifest.name,
148        });
149    }
150    if let Some(selected) = &overrides.repository
151        && !context
152            .registry
153            .record
154            .repositories
155            .iter()
156            .any(|repository| &repository.alias == selected)
157    {
158        return Err(ApplicationError::UnknownOverrideRepository(
159            selected.clone(),
160        ));
161    }
162
163    context
164        .registry
165        .record
166        .repositories
167        .iter()
168        .filter(|repository| {
169            overrides
170                .repository
171                .as_ref()
172                .is_none_or(|selected| selected == &repository.alias)
173        })
174        .map(|repository| {
175            let path = context
176                .registry
177                .checkout_path(&repository.alias)
178                .ok_or_else(|| ApplicationError::RegistryAliasMissing(repository.alias.clone()))?;
179            let effective = context
180                .repository_configs
181                .get(&repository.alias)
182                .ok_or_else(|| ApplicationError::RegistryAliasMissing(repository.alias.clone()))?;
183            Ok(SyncTarget {
184                alias: repository.alias.clone(),
185                path: path.to_path_buf(),
186                ignore_policy: effective.ignore_policy.clone(),
187                explicit_paths: explicit_watch_paths(effective),
188            })
189        })
190        .collect()
191}
192
193fn explicit_watch_paths(config: &EffectiveRepositoryConfig) -> Vec<PathBuf> {
194    let mut paths = vec![PathBuf::from(".code-system-graph.yaml")];
195    paths.extend(config.openapi.iter().map(PathBuf::from));
196    paths.extend(
197        config
198            .http_consumers
199            .iter()
200            .map(|consumer| PathBuf::from(&consumer.source)),
201    );
202    paths.extend(
203        config
204            .integration_tests
205            .iter()
206            .map(|test| PathBuf::from(&test.path)),
207    );
208    paths.extend(
209        config
210            .implementations
211            .iter()
212            .map(|implementation| PathBuf::from(&implementation.path)),
213    );
214    paths.sort();
215    paths.dedup();
216    paths
217}
218
219/// Synchronizes initialized local `CodeGraph` indexes and then publishes an incremental graph
220/// snapshot.
221///
222/// The external index is optional and best effort: a missing or failed per-repository index is
223/// reported explicitly without preventing the native Code System Graph scan.
224///
225/// # Errors
226///
227/// Returns [`ApplicationError`] when workspace validation or the native incremental scan fails.
228pub fn sync_workspace_with_overrides(
229    config_path: &Path,
230    database_path: &Path,
231    overrides: &ScanOverrides,
232    synchronize_codegraph: bool,
233) -> Result<SyncSummary, ApplicationError> {
234    super::worker::supervise_sync(config_path, database_path, overrides, synchronize_codegraph)
235}
236
237/// Synchronizes through an explicitly selected compatible worker executable.
238///
239/// Embedding applications can pass their own executable after dispatching `__worker-v1` to
240/// [`crate::run_worker_from_stdio`].
241///
242/// # Errors
243///
244/// Returns [`ApplicationError`] when the worker cannot start or synchronization fails.
245pub fn sync_workspace_with_worker_executable(
246    config_path: &Path,
247    database_path: &Path,
248    overrides: &ScanOverrides,
249    synchronize_codegraph: bool,
250    worker_executable: &Path,
251) -> Result<SyncSummary, ApplicationError> {
252    super::worker::supervise_sync_with_executable(
253        config_path,
254        database_path,
255        overrides,
256        synchronize_codegraph,
257        worker_executable,
258    )
259}
260
261#[doc(hidden)]
262pub fn sync_workspace_with_wall_time_cap(
263    config_path: &Path,
264    database_path: &Path,
265    overrides: &ScanOverrides,
266    synchronize_codegraph: bool,
267    wall_time_cap_ms: u64,
268) -> Result<SyncSummary, ApplicationError> {
269    super::worker::supervise_sync_with_wall_time_cap(
270        config_path,
271        database_path,
272        overrides,
273        synchronize_codegraph,
274        wall_time_cap_ms,
275    )
276}
277
278pub(crate) fn sync_workspace_direct(
279    config_path: &Path,
280    database_path: &Path,
281    overrides: &ScanOverrides,
282    synchronize_codegraph: bool,
283) -> Result<SyncSummary, ApplicationError> {
284    let context = load_workspace_context(config_path, overrides)?;
285    let policy = context.execution_policy;
286    let workspace = context.manifest.name;
287    let targets = workspace_sync_targets(config_path, overrides)?;
288    persist_watch_targets(database_path, &workspace, &targets)?;
289    let binary = codegraph_binary(overrides);
290    let codegraph = synchronize_codegraph_targets(&targets, synchronize_codegraph, |path| {
291        run_codegraph_sync(
292            &binary,
293            path,
294            Duration::from_millis(policy.max_codegraph_sync_wall_time_ms_per_repo),
295        )
296    });
297    let mut scan_overrides = overrides.clone();
298    scan_overrides.codegraph = codegraph.synchronized_count > 0;
299    let scan = super::scan_workspace_direct(config_path, database_path, &scan_overrides)?;
300    Ok(SyncSummary {
301        schema_version: 1,
302        execution: code_system_graph_core::ExecutionSummary::default(),
303        scan,
304        codegraph,
305    })
306}
307
308fn persist_watch_targets(
309    database_path: &Path,
310    workspace: &str,
311    targets: &[SyncTarget],
312) -> Result<(), ApplicationError> {
313    let mut encoded = Vec::with_capacity(targets.len());
314    for target in targets {
315        let payload = serde_json::to_vec(&PersistedWatchTarget::from(target))
316            .map_err(|error| ApplicationError::Initialization(error.to_string()))?;
317        if u64::try_from(payload.len()).unwrap_or(u64::MAX) > MAX_PERSISTED_WATCH_TARGET_BYTES {
318            return Err(ApplicationError::Initialization(
319                "persisted watch target exceeded its protocol bound".to_owned(),
320            ));
321        }
322        encoded.push((target.alias.clone(), payload));
323    }
324    let database_instance_id =
325        code_system_graph_store_sqlite::SqliteStore::open(database_path)?.database_instance_id()?;
326    super::work_state::WorkState::open(database_path, &database_instance_id)
327        .and_then(|mut state| state.replace_watch_scope(workspace, &encoded))
328        .map_err(ApplicationError::Initialization)
329}
330
331#[doc(hidden)]
332pub fn load_persisted_watch_targets(
333    database_path: &Path,
334    workspace: &str,
335) -> Result<Vec<SyncTarget>, ApplicationError> {
336    let database_instance_id = work_database_instance_id(database_path)?;
337    let state = super::work_state::WorkState::open(database_path, &database_instance_id)
338        .map_err(ApplicationError::Initialization)?;
339    state
340        .load_watch_scope(workspace, MAX_PERSISTED_WATCH_TARGET_BYTES)
341        .map_err(ApplicationError::Initialization)?
342        .into_iter()
343        .map(|encoded| {
344            serde_json::from_slice::<PersistedWatchTarget>(&encoded)
345                .map_err(|error| ApplicationError::Initialization(error.to_string()))?
346                .try_into()
347        })
348        .collect()
349}
350
351fn codegraph_binary(overrides: &ScanOverrides) -> OsString {
352    overrides
353        .codegraph_binary
354        .as_ref()
355        .map(|path| path.as_os_str().to_owned())
356        .or_else(|| {
357            std::env::var_os("CODE_SYSTEM_GRAPH_CODEGRAPH_BINARY").filter(|value| !value.is_empty())
358        })
359        .unwrap_or_else(|| OsString::from("codegraph"))
360}
361
362fn run_codegraph_sync(
363    binary: &OsStr,
364    project_path: &Path,
365    timeout: Duration,
366) -> Result<(), String> {
367    let mut command = Command::new(binary);
368    command
369        .arg("sync")
370        .arg("--quiet")
371        .arg(project_path)
372        .current_dir(project_path)
373        .stdin(Stdio::null())
374        .stdout(Stdio::null())
375        .stderr(Stdio::null());
376    configure_codegraph_process_group(&mut command);
377    let mut child = command
378        .spawn()
379        .map_err(|error| format!("failed to start codegraph sync: {error}"))?;
380    let started = Instant::now();
381    let status = loop {
382        if let Some(status) = child
383            .try_wait()
384            .map_err(|error| format!("failed to wait for codegraph sync: {error}"))?
385        {
386            break status;
387        }
388        if started.elapsed() >= timeout {
389            super::worker::terminate_process_tree(child.id());
390            terminate_codegraph_process_group(&mut child);
391            return Err(format!(
392                "codegraph sync exceeded {} ms and was terminated",
393                timeout.as_millis()
394            ));
395        }
396        std::thread::sleep(Duration::from_millis(50));
397    };
398    cleanup_exited_codegraph_process_group(child.id());
399    if status.success() {
400        return Ok(());
401    }
402    Err(format!("codegraph sync exited with {status}"))
403}
404
405#[cfg(unix)]
406fn configure_codegraph_process_group(command: &mut Command) {
407    use std::os::unix::process::CommandExt;
408    command.process_group(0);
409}
410
411#[cfg(not(unix))]
412fn configure_codegraph_process_group(_command: &mut Command) {}
413
414#[cfg(unix)]
415fn terminate_codegraph_process_group(child: &mut std::process::Child) {
416    if let Ok(process_group_id) = i32::try_from(child.id()) {
417        let process_group_id = nix::unistd::Pid::from_raw(process_group_id);
418        let _ = nix::sys::signal::killpg(process_group_id, nix::sys::signal::Signal::SIGTERM);
419        std::thread::sleep(Duration::from_millis(100));
420        let _ = nix::sys::signal::killpg(process_group_id, nix::sys::signal::Signal::SIGKILL);
421    }
422    let _ = child.wait();
423}
424
425#[cfg(not(unix))]
426fn terminate_codegraph_process_group(child: &mut std::process::Child) {
427    let _ = child.kill();
428    let _ = child.wait();
429}
430
431#[cfg(unix)]
432fn cleanup_exited_codegraph_process_group(process_id: u32) {
433    if let Ok(process_group_id) = i32::try_from(process_id) {
434        let _ = nix::sys::signal::killpg(
435            nix::unistd::Pid::from_raw(process_group_id),
436            nix::sys::signal::Signal::SIGKILL,
437        );
438    }
439}
440
441#[cfg(not(unix))]
442fn cleanup_exited_codegraph_process_group(process_id: u32) {
443    super::worker::terminate_process_tree(process_id);
444}
445
446fn synchronize_codegraph_targets<F>(
447    targets: &[SyncTarget],
448    enabled: bool,
449    mut synchronize: F,
450) -> CodeGraphSyncSummary
451where
452    F: FnMut(&Path) -> Result<(), String>,
453{
454    let mut repositories = Vec::with_capacity(targets.len());
455    if enabled {
456        for target in targets {
457            let (state, detail) = if target.path.join(".codegraph").is_dir() {
458                match synchronize(&target.path) {
459                    Ok(()) => (CodeGraphSyncState::Synchronized, None),
460                    Err(detail) => (CodeGraphSyncState::Failed, Some(bounded_detail(&detail))),
461                }
462            } else {
463                (
464                    CodeGraphSyncState::SkippedNotInitialized,
465                    Some("local CodeGraph index is not initialized".to_owned()),
466                )
467            };
468            repositories.push(CodeGraphRepositorySync {
469                repository: target.alias.clone(),
470                state,
471                detail,
472            });
473            super::worker::report_progress(code_system_graph_core::JobPhase::CodeGraphSync, 1);
474        }
475    }
476    repositories.sort_by(|left, right| left.repository.cmp(&right.repository));
477    let synchronized_count = repositories
478        .iter()
479        .filter(|item| item.state == CodeGraphSyncState::Synchronized)
480        .count();
481    let skipped_count = repositories
482        .iter()
483        .filter(|item| item.state == CodeGraphSyncState::SkippedNotInitialized)
484        .count();
485    let failed_count = repositories
486        .iter()
487        .filter(|item| item.state == CodeGraphSyncState::Failed)
488        .count();
489    CodeGraphSyncSummary {
490        enabled,
491        repository_count: targets.len(),
492        synchronized_count,
493        skipped_count,
494        failed_count,
495        repositories,
496    }
497}
498
499fn bounded_detail(detail: &str) -> String {
500    const MAX_CHARS: usize = 512;
501    let normalized = detail.split_whitespace().collect::<Vec<_>>().join(" ");
502    if normalized.chars().count() <= MAX_CHARS {
503        return normalized;
504    }
505    let mut bounded = normalized.chars().take(MAX_CHARS).collect::<String>();
506    bounded.push_str("...");
507    bounded
508}
509
510#[cfg(test)]
511mod tests {
512    use std::cell::RefCell;
513
514    use super::*;
515
516    fn target(alias: &str, path: PathBuf) -> SyncTarget {
517        SyncTarget {
518            alias: alias.to_owned(),
519            path,
520            ignore_policy: IgnorePolicy::new(
521                Vec::new(),
522                code_system_graph_core::ConfigSource::Default,
523                Vec::new(),
524                code_system_graph_core::ConfigSource::Default,
525            )
526            .expect("built-in ignore policy"),
527            explicit_paths: Vec::new(),
528        }
529    }
530
531    #[test]
532    fn persisted_watch_scope_should_preserve_every_alias_policy() -> anyhow::Result<()> {
533        let temporary = tempfile::tempdir()?;
534        let database = temporary.path().join("graph.db");
535        let shared = temporary.path().join("shared");
536        std::fs::create_dir(&shared)?;
537        let mut restrictive = target("a-restrictive", shared.clone());
538        restrictive.ignore_policy = IgnorePolicy::new(
539            vec!["generated/**".to_owned()],
540            ConfigSource::WorkspaceManifest,
541            Vec::new(),
542            ConfigSource::Default,
543        )?;
544        let permissive = target("b-permissive", shared);
545        let expected = vec![restrictive, permissive];
546
547        persist_watch_targets(&database, "workspace", &expected)?;
548        let actual = load_persisted_watch_targets(&database, "workspace")?;
549
550        assert_eq!(actual, expected);
551        Ok(())
552    }
553
554    #[test]
555    fn synchronization_should_skip_uninitialized_indexes_and_bound_failures() -> anyhow::Result<()>
556    {
557        let temporary = tempfile::tempdir()?;
558        let initialized = temporary.path().join("initialized");
559        let absent = temporary.path().join("absent");
560        std::fs::create_dir_all(initialized.join(".codegraph"))?;
561        std::fs::create_dir(&absent)?;
562        let called = RefCell::new(Vec::new());
563        let targets = vec![target("zeta", initialized.clone()), target("alpha", absent)];
564
565        let report = synchronize_codegraph_targets(&targets, true, |path| {
566            called.borrow_mut().push(path.to_path_buf());
567            Err("failure ".repeat(600))
568        });
569
570        assert_eq!(called.into_inner(), vec![initialized]);
571        assert_eq!(report.repository_count, 2);
572        assert_eq!(report.synchronized_count, 0);
573        assert_eq!(report.skipped_count, 1);
574        assert_eq!(report.failed_count, 1);
575        assert_eq!(report.repositories[0].repository, "alpha");
576        assert_eq!(
577            report.repositories[0].state,
578            CodeGraphSyncState::SkippedNotInitialized
579        );
580        assert!(
581            report.repositories[1]
582                .detail
583                .as_deref()
584                .is_some_and(|detail| detail.chars().count() <= 515)
585        );
586        Ok(())
587    }
588
589    #[test]
590    fn disabled_codegraph_sync_should_not_invoke_runner() {
591        let target = target("repo", PathBuf::from("repo"));
592        let report = synchronize_codegraph_targets(&[target], false, |_| {
593            panic!("disabled synchronization must not invoke CodeGraph")
594        });
595        assert!(!report.enabled);
596        assert_eq!(report.repository_count, 1);
597        assert!(report.repositories.is_empty());
598    }
599
600    #[cfg(unix)]
601    #[test]
602    fn codegraph_timeout_should_terminate_its_descendant_process() -> anyhow::Result<()> {
603        use std::os::unix::fs::PermissionsExt;
604
605        let temporary = tempfile::tempdir()?;
606        let script = temporary.path().join("codegraph-test");
607        let descendant_pid = temporary.path().join("descendant.pid");
608        std::fs::write(
609            &script,
610            format!(
611                "#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\" > '{}'\nwait\n",
612                descendant_pid.display()
613            ),
614        )?;
615        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700))?;
616
617        let error =
618            run_codegraph_sync(script.as_os_str(), temporary.path(), Duration::from_secs(2))
619                .expect_err("test process must time out");
620        assert!(
621            error.contains("was terminated"),
622            "unexpected error: {error}"
623        );
624        let pid = std::fs::read_to_string(&descendant_pid)?.parse::<i32>()?;
625        for _ in 0..20 {
626            if nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), None).is_err() {
627                return Ok(());
628            }
629            std::thread::sleep(Duration::from_millis(10));
630        }
631        anyhow::bail!("CodeGraph descendant {pid} survived timeout")
632    }
633
634    #[cfg(unix)]
635    #[test]
636    fn successful_codegraph_sync_should_terminate_surviving_descendants() -> anyhow::Result<()> {
637        use std::os::unix::fs::PermissionsExt;
638
639        let temporary = tempfile::tempdir()?;
640        let script = temporary.path().join("codegraph-test");
641        let descendant_pid = temporary.path().join("descendant.pid");
642        std::fs::write(
643            &script,
644            format!(
645                "#!/bin/sh\nsh -c 'trap \"\" TERM; sleep 30' &\nprintf '%s' \"$!\" > '{}'\nexit 0\n",
646                descendant_pid.display()
647            ),
648        )?;
649        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700))?;
650
651        run_codegraph_sync(script.as_os_str(), temporary.path(), Duration::from_secs(2))
652            .map_err(anyhow::Error::msg)?;
653        let pid = std::fs::read_to_string(&descendant_pid)?.parse::<i32>()?;
654        for _ in 0..20 {
655            if nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), None).is_err() {
656                return Ok(());
657            }
658            std::thread::sleep(Duration::from_millis(10));
659        }
660        anyhow::bail!("CodeGraph descendant {pid} survived successful sync")
661    }
662}