1use 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#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct SyncTarget {
21 pub alias: String,
23 pub path: PathBuf,
25 pub ignore_policy: IgnorePolicy,
27 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
80#[serde(rename_all = "snake_case")]
81pub enum CodeGraphSyncState {
82 Synchronized,
84 SkippedNotInitialized,
86 Failed,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
92pub struct CodeGraphRepositorySync {
93 pub repository: String,
95 pub state: CodeGraphSyncState,
97 pub detail: Option<String>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
103pub struct CodeGraphSyncSummary {
104 pub enabled: bool,
106 pub repository_count: usize,
108 pub synchronized_count: usize,
110 pub skipped_count: usize,
112 pub failed_count: usize,
114 pub repositories: Vec<CodeGraphRepositorySync>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
120pub struct SyncSummary {
121 pub schema_version: u8,
123 pub execution: code_system_graph_core::ExecutionSummary,
125 pub scan: ScanSummary,
127 pub codegraph: CodeGraphSyncSummary,
129}
130
131pub 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
219pub 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
237pub 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}