1use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::path::{Component, Path, PathBuf};
5use std::sync::atomic::{AtomicU32, Ordering};
6use std::sync::Arc;
7use std::sync::Mutex;
8
9use anyhow::{bail, Context, Result};
10use perspt_sdk::{CandidateStateWitness, ToolEntry};
11use tempfile::TempDir;
12
13use crate::realize::snapshot_workspace;
14use crate::toolloop::{CandidateCheckpoint, EffectExecutor, EffectOutcome};
15use crate::tools::AgentTools;
16use crate::verifier::{run_governed_verifier, VerifierExecution};
17
18pub use crate::measure::CodingCandidateMeasurer;
19
20pub struct CandidateWorkspace {
23 source_root: PathBuf,
24 overlay_root: PathBuf,
25 _temp: TempDir,
26 overlay_tools: AgentTools,
27 source_tools: AgentTools,
28 tracked: Mutex<BTreeSet<String>>,
31 mutated_paths: Mutex<BTreeSet<String>>,
35 source_preimages: Mutex<BTreeMap<String, Option<Vec<u8>>>>,
38 journal: Mutex<Vec<JournalEntry>>,
42 snapshots: Mutex<HashMap<String, CandidateSnapshot>>,
43 lsp_sessions: crate::tools::handlers::LspSessions,
44 mutations: AtomicU32,
45 node_id: String,
46 generation: u32,
47 graph_revision: String,
48 allow_unisolated_verifiers: bool,
49 verifier_timeouts: crate::verifier::VerifierTimeouts,
51 handlers: Arc<crate::tools::handlers::CandidateHandlerRegistry>,
54}
55
56impl std::fmt::Debug for CandidateWorkspace {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 f.debug_struct("CandidateWorkspace")
59 .field("source_root", &self.source_root)
60 .field("overlay_root", &self.overlay_root)
61 .field("node_id", &self.node_id)
62 .field("generation", &self.generation)
63 .finish_non_exhaustive()
64 }
65}
66
67impl CandidateWorkspace {
68 pub fn create(
69 source_root: &Path,
70 node_id: impl Into<String>,
71 generation: u32,
72 graph_revision: impl Into<String>,
73 ) -> Result<Self> {
74 Self::create_with_policy(source_root, node_id, generation, graph_revision, false)
75 }
76
77 pub fn create_with_policy(
78 source_root: &Path,
79 node_id: impl Into<String>,
80 generation: u32,
81 graph_revision: impl Into<String>,
82 allow_unisolated_verifiers: bool,
83 ) -> Result<Self> {
84 Self::create_filtered(
85 source_root,
86 node_id,
87 generation,
88 graph_revision,
89 allow_unisolated_verifiers,
90 &[],
91 )
92 }
93
94 pub(crate) fn create_filtered(
97 source_root: &Path,
98 node_id: impl Into<String>,
99 generation: u32,
100 graph_revision: impl Into<String>,
101 allow_unisolated_verifiers: bool,
102 excluded: &[PathBuf],
103 ) -> Result<Self> {
104 let source_root = source_root
105 .canonicalize()
106 .with_context(|| format!("canonicalizing {}", source_root.display()))?;
107 let temp = tempfile::Builder::new()
108 .prefix("perspt-candidate-")
109 .tempdir()?;
110 let overlay_root = temp.path().join("workspace");
111 std::fs::create_dir_all(&overlay_root)?;
112 let overlay_root = overlay_root.canonicalize()?;
113 copy_workspace_filtered(&source_root, &overlay_root, excluded)?;
114 Ok(Self {
115 overlay_tools: AgentTools::new(overlay_root.clone()),
116 source_tools: AgentTools::new(source_root.clone()),
117 source_root,
118 overlay_root,
119 _temp: temp,
120 tracked: Mutex::new(BTreeSet::new()),
121 mutated_paths: Mutex::new(BTreeSet::new()),
122 source_preimages: Mutex::new(BTreeMap::new()),
123 journal: Mutex::new(Vec::new()),
124 snapshots: Mutex::new(HashMap::new()),
125 lsp_sessions: tokio::sync::Mutex::new(HashMap::new()),
126 mutations: AtomicU32::new(0),
127 node_id: node_id.into(),
128 generation,
129 graph_revision: graph_revision.into(),
130 allow_unisolated_verifiers,
131 verifier_timeouts: crate::verifier::VerifierTimeouts::default(),
132 handlers: Arc::new(crate::tools::handlers::CandidateHandlerRegistry::with_builtins()),
133 })
134 }
135
136 pub fn set_tool_handlers(
139 &mut self,
140 handlers: Arc<crate::tools::handlers::CandidateHandlerRegistry>,
141 ) {
142 self.handlers = handlers;
143 }
144
145 pub fn overlay_root(&self) -> &Path {
146 &self.overlay_root
147 }
148
149 pub(crate) fn overlay_tools(&self) -> &AgentTools {
150 &self.overlay_tools
151 }
152
153 pub(crate) fn source_tools(&self) -> &AgentTools {
154 &self.source_tools
155 }
156
157 pub(crate) fn lsp_sessions(&self) -> &crate::tools::handlers::LspSessions {
158 &self.lsp_sessions
159 }
160
161 pub(crate) fn unisolated_verifiers_allowed(&self) -> bool {
162 self.allow_unisolated_verifiers
163 }
164
165 pub fn set_verifier_timeouts(&mut self, timeouts: crate::verifier::VerifierTimeouts) {
168 self.verifier_timeouts = timeouts;
169 }
170
171 pub(crate) fn verifier_timeouts(&self) -> crate::verifier::VerifierTimeouts {
172 self.verifier_timeouts
173 }
174
175 pub(crate) fn validate_relative(&self, path: &str) -> Result<String> {
177 validate_relative_path(path)
178 }
179
180 pub fn has_mutated(&self) -> bool {
181 self.mutations.load(Ordering::SeqCst) > 0
182 }
183
184 pub fn node_generation(&self) -> u32 {
186 self.generation
187 }
188
189 pub fn touched_paths(&self) -> Vec<String> {
191 self.mutated_paths.lock().unwrap().iter().cloned().collect()
192 }
193
194 pub fn realized_diff(&self) -> Result<String> {
198 let mut output = String::new();
199 for relative in self.touched_paths() {
200 let before =
201 std::fs::read_to_string(self.source_root.join(&relative)).unwrap_or_default();
202 let after =
203 std::fs::read_to_string(self.overlay_root.join(&relative)).unwrap_or_default();
204 output.push_str(&format!("diff --perspt {relative}\n"));
205 output.push_str(
206 &diffy::PatchFormatter::new()
207 .fmt_patch(&diffy::create_patch(&before, &after))
208 .to_string(),
209 );
210 }
211 Ok(output)
212 }
213
214 pub fn promote(&self) -> Result<Vec<String>> {
219 let paths: Vec<String> = self.mutated_paths.lock().unwrap().iter().cloned().collect();
220 let source = crate::promote::WorkspaceRoot::open(&self.source_root)?;
221 let overlay = crate::promote::WorkspaceRoot::open(&self.overlay_root)?;
222 let mut staged = Vec::new();
223 for rel in &paths {
224 let rel = validate_relative_path(rel)?;
225 let target = source.target_dir(&rel, true)?;
226 let current = target.read_optional()?;
227 let expected = self
228 .source_preimages
229 .lock()
230 .unwrap()
231 .get(&rel)
232 .cloned()
233 .context("mutated path has no source pre-image")?;
234 anyhow::ensure!(
235 current == expected,
236 "workspace path changed since the candidate was created: {rel}"
237 );
238 let after = overlay.read_if_present(&rel)?;
239 staged.push((rel, target, current, after));
240 }
241
242 for (promoted, (rel, target, _before, after)) in staged.iter().enumerate() {
243 if let Err(error) = target.apply(after.as_deref()) {
244 let error = error.context(format!("promoting {rel}"));
245 return Err(rollback_promotion(&staged[..promoted], error));
246 }
247 }
248 Ok(paths)
249 }
250
251 fn scope_snapshot(&self, additional: &[String]) -> Result<CandidateCheckpoint> {
252 let mut tracked = self.tracked.lock().unwrap();
253 for rel in additional {
254 tracked.insert(validate_relative_path(rel)?);
255 }
256 let scope: Vec<String> = tracked.iter().cloned().collect();
257 drop(tracked);
258
259 let witness = self.witness_for(scope)?;
260 let id = uuid::Uuid::new_v4().to_string();
261 self.snapshots.lock().unwrap().insert(
262 id.clone(),
263 CandidateSnapshot {
264 journal_len: self.journal.lock().unwrap().len(),
265 mutations: self.mutations.load(Ordering::SeqCst),
266 mutated: self.mutated_paths.lock().unwrap().clone(),
267 },
268 );
269 Ok(CandidateCheckpoint { id, witness })
270 }
271
272 fn current_witness(&self) -> Result<CandidateStateWitness> {
273 let scope: Vec<String> = self.tracked.lock().unwrap().iter().cloned().collect();
274 self.witness_for(scope)
275 }
276
277 fn witness_for(&self, scope: Vec<String>) -> Result<CandidateStateWitness> {
282 let state = snapshot_workspace(&self.overlay_root, &scope)?;
283 let mutated = self.mutated_paths.lock().unwrap();
284 let barrier_channels = perspt_coding::OperationalSafetyBarrier::default()
285 .measure_channels(mutated.iter().map(String::as_str));
286 Ok(CandidateStateWitness {
287 state_root: state.root_hash(),
288 graph_revision: self.graph_revision.clone(),
289 node_id: self.node_id.clone(),
290 node_generation: self.generation,
291 canonical_scope: scope,
292 barrier_channels,
293 })
294 }
295
296 fn admit_named_paths(
300 &self,
301 call: &perspt_sdk::ProviderToolCall,
302 entry: &ToolEntry,
303 mutating: bool,
304 ) -> Result<Vec<String>> {
305 let named_paths: Vec<String> = ["path", "to", "from"]
306 .iter()
307 .filter_map(|field| call.arguments.get(*field).and_then(|v| v.as_str()))
308 .map(str::to_string)
309 .collect();
310 for rel in &named_paths {
311 validate_relative_path(rel)?;
312 if !entry.effect.is_read_only() {
313 reject_symlink_ancestor(&self.overlay_root, rel)?;
314 }
315 }
316 if mutating {
317 for rel in &named_paths {
318 self.journal_pre_image(rel)?;
319 }
320 }
321 Ok(named_paths)
322 }
323
324 pub(crate) fn admit_external_paths(&self, paths: &[String]) -> Result<()> {
328 for rel in paths {
329 let rel = validate_relative_path(rel)?;
330 reject_symlink_ancestor(&self.overlay_root, &rel)?;
331 self.journal_pre_image(&rel)?;
332 self.tracked.lock().unwrap().insert(rel);
333 }
334 Ok(())
335 }
336
337 pub(crate) fn note_mutated_paths(&self, paths: &[String]) -> Result<()> {
340 let mut mutated = self.mutated_paths.lock().unwrap();
341 for rel in paths {
342 mutated.insert(validate_relative_path(rel)?);
343 }
344 Ok(())
345 }
346
347 pub(crate) fn overlay_bytes(&self, rel: &str) -> Result<Option<Vec<u8>>> {
349 let rel = validate_relative_path(rel)?;
350 match std::fs::read(self.overlay_root.join(&rel)) {
351 Ok(bytes) => Ok(Some(bytes)),
352 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
353 Err(e) => Err(e).with_context(|| format!("reading {rel}")),
354 }
355 }
356
357 pub fn restore_exported(&self, files: &[crate::toolloop::SeedFile]) -> Result<()> {
361 self.restore_files(files, true)
362 }
363
364 pub fn restore_seeded(&self, files: &[crate::toolloop::SeedFile]) -> Result<()> {
371 self.restore_files(files, false)
372 }
373
374 fn restore_files(
375 &self,
376 files: &[crate::toolloop::SeedFile],
377 mark_promotable: bool,
378 ) -> Result<()> {
379 for seed in files {
380 let rel = validate_relative_path(&seed.path)?;
381 reject_symlink_ancestor(&self.overlay_root, &rel)?;
382 self.journal_pre_image(&rel)?;
383 let path = self.overlay_root.join(&rel);
384 match &seed.content {
385 Some(bytes) => {
386 if let Some(parent) = path.parent() {
387 std::fs::create_dir_all(parent)?;
388 }
389 std::fs::write(&path, bytes)?;
390 }
391 None if path.is_file() => std::fs::remove_file(&path)?,
392 None => {}
393 }
394 self.tracked.lock().unwrap().insert(rel.clone());
395 self.source_preimages
396 .lock()
397 .unwrap()
398 .insert(rel.clone(), seed.source_preimage.clone());
399 if mark_promotable {
400 self.mutated_paths.lock().unwrap().insert(rel);
401 self.mutations.fetch_add(1, Ordering::SeqCst);
402 }
403 }
404 Ok(())
405 }
406
407 fn journal_pre_image(&self, rel: &str) -> Result<()> {
409 let path = self.overlay_root.join(rel);
410 let prior = match std::fs::read(&path) {
411 Ok(bytes) => Some(bytes),
412 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
413 Err(e) => return Err(e).with_context(|| format!("journaling {rel}")),
414 };
415 self.source_preimages
416 .lock()
417 .unwrap()
418 .entry(rel.to_string())
419 .or_insert_with(|| prior.clone());
420 self.journal.lock().unwrap().push(JournalEntry {
421 path: rel.to_string(),
422 prior,
423 });
424 Ok(())
425 }
426
427 fn restore_snapshot(&self, checkpoint: &CandidateCheckpoint) -> Result<()> {
431 let snapshots = self.snapshots.lock().unwrap();
432 let snapshot = snapshots
433 .get(&checkpoint.id)
434 .with_context(|| format!("unknown candidate checkpoint {}", checkpoint.id))?;
435 let mut journal = self.journal.lock().unwrap();
436 while journal.len() > snapshot.journal_len {
437 let entry = journal.pop().expect("journal length checked");
438 let path = self.overlay_root.join(&entry.path);
439 match entry.prior {
440 Some(bytes) => {
441 if let Some(parent) = path.parent() {
442 std::fs::create_dir_all(parent)?;
443 }
444 std::fs::write(&path, bytes)?;
445 }
446 None if path.is_file() => std::fs::remove_file(&path)?,
447 None => {}
448 }
449 }
450 self.mutations.store(snapshot.mutations, Ordering::SeqCst);
451 *self.mutated_paths.lock().unwrap() = snapshot.mutated.clone();
452 Ok(())
453 }
454
455 pub(crate) fn commands_for(
463 &self,
464 tool: &str,
465 arguments: &serde_json::Value,
466 ) -> Result<Vec<(String, String, perspt_core::plugin::VerifierStage)>> {
467 use perspt_core::plugin::VerifierStage;
468 let registry = perspt_core::PluginRegistry::new();
469 let plugins = registry.detect_all(&self.overlay_root);
470 if plugins.is_empty() {
471 bail!("no language plugin detected");
472 }
473 let mutated = self.mutated_paths.lock().unwrap().clone();
474 let owned: Vec<usize> = (0..plugins.len())
475 .filter(|&i| mutated.iter().any(|path| plugins[i].owns_file(path)))
476 .collect();
477 let selected: Vec<usize> = if owned.is_empty() {
478 (0..plugins.len()).collect()
479 } else {
480 owned
481 };
482 let filter = match arguments.get("filter").and_then(serde_json::Value::as_str) {
483 Some(raw) => Some(sanitize_test_filter(raw)?),
484 None => None,
485 };
486 let mut commands = Vec::new();
487 for index in selected {
488 let plugin = &plugins[index];
489 let command = match tool {
490 "run_test" => Some((
491 plugin.test_command_with_filter(filter.as_deref()),
492 VerifierStage::Test,
493 )),
494 "run_build" => plugin
495 .build_command()
496 .or_else(|| plugin.syntax_check_command())
497 .map(|command| (command, VerifierStage::Build)),
498 "run_formatter" => plugin
499 .format_command()
500 .map(|command| (command, VerifierStage::Format)),
501 _ => bail!("not a verifier tool: {tool}"),
502 };
503 if let Some((command, stage)) = command {
504 commands.push((plugin.name().to_string(), command, stage));
505 }
506 }
507 if commands.is_empty() {
508 match tool {
509 "run_build" => bail!("active language has no build or syntax command"),
510 "run_formatter" => bail!("active language has no governed formatter command"),
511 other => bail!("active language has no {other} command"),
512 }
513 }
514 Ok(commands)
515 }
516
517 pub(crate) async fn run_governed_verifier(
518 &self,
519 command: &str,
520 stage: Option<perspt_core::plugin::VerifierStage>,
521 ) -> Result<VerifierExecution> {
522 run_governed_verifier(
523 self.overlay_root.clone(),
524 command.to_string(),
525 self.allow_unisolated_verifiers,
526 crate::measure::SHARED_TARGET_SUFFIX.into(),
527 self.verifier_env(),
528 self.verifier_timeouts.for_stage(stage),
529 )
530 .await
531 }
532
533 pub(crate) fn verifier_env(&self) -> Vec<(String, String)> {
538 let venv = self.source_root.join(".venv");
539 if venv.is_dir() {
540 vec![("UV_PROJECT_ENVIRONMENT".into(), venv.display().to_string())]
541 } else {
542 Vec::new()
543 }
544 }
545
546 pub(crate) fn backward_compatibility_tests(&self) -> Result<Option<TestEvidenceWorkspace>> {
552 let registry = perspt_core::PluginRegistry::new();
553 let plugins = registry.detect_all(&self.overlay_root);
554 let preimages = self.source_preimages.lock().unwrap();
555 let touched_tests: Vec<_> = self
556 .mutated_paths
557 .lock()
558 .unwrap()
559 .iter()
560 .filter(|path| plugins.iter().any(|plugin| plugin.is_test_file(path)))
561 .filter_map(|path| {
562 preimages
563 .get(path)
564 .cloned()
565 .map(|content| (path.clone(), content))
566 })
567 .collect();
568 if !touched_tests
569 .iter()
570 .any(|(_, source_preimage)| source_preimage.is_some())
571 {
572 return Ok(None);
573 }
574
575 let temp = tempfile::Builder::new()
576 .prefix("perspt-regression-tests-")
577 .tempdir()?;
578 let root = temp.path().join("workspace");
579 std::fs::create_dir_all(&root)?;
580 let root = root.canonicalize()?;
581 copy_workspace(&self.overlay_root, &root)?;
582 for (relative, source_preimage) in touched_tests {
583 let path = root.join(&relative);
584 match source_preimage {
585 Some(bytes) => {
586 if let Some(parent) = path.parent() {
587 std::fs::create_dir_all(parent)?;
588 }
589 std::fs::write(path, bytes)?;
590 }
591 None if path.is_file() => std::fs::remove_file(path)?,
592 None => {}
593 }
594 }
595 Ok(Some(TestEvidenceWorkspace { _temp: temp, root }))
596 }
597
598 pub(crate) fn external_oracle_tests(
603 &self,
604 configured_path: &Path,
605 ) -> Result<TestEvidenceWorkspace> {
606 let oracle_path = if configured_path.is_absolute() {
607 configured_path.to_path_buf()
608 } else {
609 self.source_root.join(configured_path)
610 };
611 let oracle_path = oracle_path.canonicalize().with_context(|| {
612 format!(
613 "canonicalizing external test oracle {}",
614 oracle_path.display()
615 )
616 })?;
617 anyhow::ensure!(
618 oracle_path.is_dir(),
619 "external test oracle is not a directory: {}",
620 oracle_path.display()
621 );
622 anyhow::ensure!(
623 oracle_path != self.source_root && !self.source_root.starts_with(&oracle_path),
624 "external test oracle must be a dedicated directory, not the workspace or one of \
625 its ancestors: {}",
626 oracle_path.display()
627 );
628 let temp = tempfile::Builder::new()
629 .prefix("perspt-external-oracle-")
630 .tempdir()?;
631 let root = temp.path().join("workspace");
632 std::fs::create_dir_all(&root)?;
633 let root = root.canonicalize()?;
634 copy_workspace(&self.overlay_root, &root)?;
635 copy_workspace(&oracle_path, &root)?;
636 Ok(TestEvidenceWorkspace { _temp: temp, root })
637 }
638}
639
640#[async_trait::async_trait]
641impl EffectExecutor for CandidateWorkspace {
642 async fn checkpoint(&self, scope: &[String]) -> Result<CandidateCheckpoint> {
643 self.scope_snapshot(scope)
644 }
645
646 async fn apply(
647 &self,
648 call: &perspt_sdk::ProviderToolCall,
649 entry: &ToolEntry,
650 ) -> Result<EffectOutcome> {
651 let mutating = crate::toolloop::candidate_mutating_effect(entry.effect);
652 let named_paths = self.admit_named_paths(call, entry, mutating)?;
653
654 let Some(handler) = self.handlers.resolve(&call.name).cloned() else {
655 return Ok(EffectOutcome {
656 output: format!("tool failed: no executor registered for {}", call.name),
657 mutated: false,
658 completed: true,
659 });
660 };
661 let outcome = handler.apply(self, call, entry).await?;
662
663 let mutates_source = outcome.mutated && mutating;
666 if mutates_source {
667 self.mutations.fetch_add(1, Ordering::SeqCst);
668 let mut mutated = self.mutated_paths.lock().unwrap();
669 for rel in &named_paths {
670 mutated.insert(rel.clone());
671 }
672 }
673 Ok(EffectOutcome {
674 output: outcome.output,
675 mutated: mutates_source,
676 completed: outcome.completed,
677 })
678 }
679
680 async fn restore(&self, checkpoint: &CandidateCheckpoint) -> Result<()> {
681 self.restore_snapshot(checkpoint)
682 }
683
684 async fn state_witness(&self) -> Result<CandidateStateWitness> {
685 self.current_witness()
686 }
687
688 async fn export_accepted(&self) -> Result<Vec<crate::toolloop::SeedFile>> {
689 let mutated = self.touched_paths();
690 let preimages = self.source_preimages.lock().unwrap();
691 let mut exported = Vec::with_capacity(mutated.len());
692 for rel in mutated {
693 let path = self.overlay_root.join(&rel);
694 let bytes = match std::fs::read(&path) {
695 Ok(bytes) => Some(bytes),
696 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
697 Err(e) => return Err(e).with_context(|| format!("exporting {rel}")),
698 };
699 let source_preimage = preimages
700 .get(&rel)
701 .cloned()
702 .context("mutated path has no source pre-image")?;
703 exported.push(crate::toolloop::SeedFile {
704 path: rel,
705 content: bytes,
706 source_preimage,
707 });
708 }
709 Ok(exported)
710 }
711}
712
713struct CandidateSnapshot {
714 journal_len: usize,
716 mutations: u32,
717 mutated: BTreeSet<String>,
718}
719
720struct JournalEntry {
722 path: String,
723 prior: Option<Vec<u8>>,
724}
725
726#[derive(Debug)]
727pub(crate) struct TestEvidenceWorkspace {
728 _temp: TempDir,
729 pub(crate) root: PathBuf,
730}
731
732fn sanitize_test_filter(raw: &str) -> Result<String> {
736 let filter = raw.trim();
737 if filter.is_empty() {
738 bail!("'filter' must not be empty");
739 }
740 if !filter
741 .chars()
742 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | ':' | '.' | '-'))
743 {
744 bail!(
745 "'filter' may only contain letters, digits, and _ : . - \
746 (got {filter:?})"
747 );
748 }
749 Ok(filter.to_string())
750}
751
752fn validate_relative_path(path: &str) -> Result<String> {
753 let value = Path::new(path);
754 if value.as_os_str().is_empty()
755 || value.is_absolute()
756 || value.components().any(|component| {
757 matches!(
758 component,
759 Component::ParentDir | Component::RootDir | Component::Prefix(_)
760 )
761 })
762 {
763 bail!("path must be a non-empty workspace-relative path: {path:?}");
764 }
765 Ok(value
766 .components()
767 .filter_map(|component| match component {
768 Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
769 _ => None,
770 })
771 .collect::<Vec<_>>()
772 .join("/"))
773}
774
775type StagedPromotion = (
778 String,
779 crate::promote::TargetDir,
780 Option<Vec<u8>>,
781 Option<Vec<u8>>,
782);
783
784fn rollback_promotion(promoted: &[StagedPromotion], error: anyhow::Error) -> anyhow::Error {
789 let mut failures = Vec::new();
790 for (rel, target, before, _) in promoted {
791 if let Err(restore) = target.apply(before.as_deref()) {
792 failures.push(format!("{rel}: {restore:#}"));
793 }
794 }
795 if failures.is_empty() {
796 error
797 } else {
798 error.context(format!(
799 "rollback left the workspace inconsistent: {}",
800 failures.join("; ")
801 ))
802 }
803}
804
805pub(crate) fn reject_symlink_ancestor(root: &Path, relative: &str) -> Result<()> {
806 let mut current = root.to_path_buf();
807 for component in Path::new(relative).components() {
808 if let Component::Normal(part) = component {
809 current.push(part);
810 match std::fs::symlink_metadata(¤t) {
811 Ok(metadata) if metadata.file_type().is_symlink() => {
812 bail!("mutating through a candidate symlink is forbidden: {relative}")
813 }
814 Ok(_) => {}
815 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
816 Err(error) => return Err(error.into()),
817 }
818 }
819 }
820 Ok(())
821}
822
823fn copy_workspace(source: &Path, destination: &Path) -> Result<()> {
824 copy_workspace_filtered(source, destination, &[])
825}
826
827fn copy_workspace_filtered(source: &Path, destination: &Path, excluded: &[PathBuf]) -> Result<()> {
828 for entry in std::fs::read_dir(source)? {
829 let entry = entry?;
830 let name = entry.file_name();
831 if matches!(
832 name.to_str(),
833 Some(
834 ".git"
835 | ".perspt"
836 | ".perspt-target"
837 | ".perspt-tmp"
838 | ".perspt-home"
839 | ".venv"
840 | ".pytest_cache"
841 | ".ruff_cache"
842 | "__pycache__"
843 | "target"
844 )
845 ) {
846 continue;
847 }
848 let from = entry.path();
849 if excluded.iter().any(|path| path == &from) {
850 continue;
851 }
852 let to = destination.join(&name);
853 let kind = entry.file_type()?;
854 if kind.is_dir() {
855 if name == "node_modules" {
856 link_dependency_dir(&from, &to)?;
857 } else {
858 std::fs::create_dir_all(&to)?;
859 copy_workspace_filtered(&from, &to, excluded)?;
860 }
861 } else if kind.is_file() {
862 std::fs::copy(&from, &to)?;
863 }
864 }
865 Ok(())
866}
867
868#[cfg(unix)]
869fn link_dependency_dir(source: &Path, destination: &Path) -> Result<()> {
870 std::os::unix::fs::symlink(source, destination)?;
871 Ok(())
872}
873
874#[cfg(not(unix))]
875fn link_dependency_dir(source: &Path, destination: &Path) -> Result<()> {
876 std::fs::create_dir_all(destination)?;
877 copy_workspace(source, destination)
878}
879
880#[cfg(test)]
881mod tests {
882 use super::*;
883
884 fn mixed_language_source() -> tempfile::TempDir {
886 let source = tempfile::tempdir().unwrap();
887 std::fs::write(
888 source.path().join("Cargo.toml"),
889 "[package]\nname = \"x\"\nversion = \"0.0.1\"\n",
890 )
891 .unwrap();
892 std::fs::create_dir_all(source.path().join("src")).unwrap();
893 std::fs::write(source.path().join("src/lib.rs"), "").unwrap();
894 std::fs::write(
895 source.path().join("pyproject.toml"),
896 "[project]\nname = \"x\"\nversion = \"0.0.1\"\n",
897 )
898 .unwrap();
899 source
900 }
901
902 #[tokio::test]
903 async fn mixed_repo_interactive_tests_cover_all_plugins_until_a_mutation_selects_one() {
904 let source = mixed_language_source();
905 let candidate = CandidateWorkspace::create(source.path(), "n1", 0, "r1").unwrap();
906
907 let all = candidate
908 .commands_for("run_test", &serde_json::json!({}))
909 .unwrap();
910 assert!(all.len() >= 2, "both plugins must run before any mutation");
911
912 candidate
913 .apply(&write_call("src/lib.rs", "pub fn f() {}"), &write_entry())
914 .await
915 .unwrap();
916 let narrowed = candidate
917 .commands_for("run_test", &serde_json::json!({}))
918 .unwrap();
919 assert_eq!(narrowed.len(), 1);
920 assert!(narrowed[0].1.starts_with("cargo test"));
921 }
922
923 #[test]
924 fn run_test_filter_threads_through_and_rejects_injection() {
925 let source = mixed_language_source();
926 let candidate = CandidateWorkspace::create(source.path(), "n1", 0, "r1").unwrap();
927
928 let filtered = candidate
929 .commands_for("run_test", &serde_json::json!({"filter": "my_test::case"}))
930 .unwrap();
931 assert!(filtered
932 .iter()
933 .any(|(_, command, _)| command.contains("my_test::case")));
934
935 let injected =
936 candidate.commands_for("run_test", &serde_json::json!({"filter": "x; rm -rf /"}));
937 assert!(injected.is_err(), "shell metacharacters must be refused");
938 }
939
940 #[tokio::test]
943 async fn seeded_files_are_not_promotable_until_touched() {
944 let source = tempfile::tempdir().unwrap();
945 std::fs::write(source.path().join("Cargo.toml"), "[package]\n").unwrap();
946 let candidate = CandidateWorkspace::create(source.path(), "n1", 0, "r1").unwrap();
947 candidate
948 .restore_seeded(&[crate::toolloop::SeedFile {
949 path: "src/inherited.rs".into(),
950 content: Some(b"pub fn one() {}".to_vec()),
951 source_preimage: None,
952 }])
953 .unwrap();
954 assert!(!candidate.has_mutated(), "seeding is not a mutation");
955 assert!(candidate.touched_paths().is_empty());
956
957 candidate
958 .apply(&write_call("src/own.rs", "pub fn two() {}"), &write_entry())
959 .await
960 .unwrap();
961 assert_eq!(candidate.touched_paths(), vec!["src/own.rs".to_string()]);
962 assert!(candidate.overlay_root().join("src/inherited.rs").is_file());
964 }
965
966 #[test]
967 fn paths_cannot_escape_the_candidate() {
968 assert!(validate_relative_path("src/lib.rs").is_ok());
969 assert!(validate_relative_path("../outside").is_err());
970 assert!(validate_relative_path("/tmp/outside").is_err());
971 }
972
973 #[cfg(unix)]
974 #[test]
975 fn mutation_cannot_cross_an_overlay_symlink() {
976 let overlay = tempfile::tempdir().unwrap();
977 let outside = tempfile::tempdir().unwrap();
978 std::os::unix::fs::symlink(outside.path(), overlay.path().join("deps")).unwrap();
979 assert!(reject_symlink_ancestor(overlay.path(), "deps/package/file.rs").is_err());
980 assert!(reject_symlink_ancestor(overlay.path(), "src/file.rs").is_ok());
981 }
982
983 fn write_call(path: &str, content: &str) -> perspt_sdk::ProviderToolCall {
984 perspt_sdk::ProviderToolCall {
985 call_id: uuid::Uuid::new_v4().to_string(),
986 name: "write_file".into(),
987 arguments: serde_json::json!({"path": path, "content": content}),
988 }
989 }
990
991 fn write_entry() -> ToolEntry {
992 perspt_sdk::base_entries()
993 .into_iter()
994 .find(|entry| entry.name == "write_file")
995 .expect("base catalog has write_file")
996 }
997
998 #[tokio::test]
999 async fn checkpoint_restore_and_promotion_use_touched_paths() {
1000 let source = tempfile::tempdir().unwrap();
1001 std::fs::write(source.path().join("a.txt"), "old").unwrap();
1002 let candidate = CandidateWorkspace::create(source.path(), "n1", 0, "r1").unwrap();
1003 let before = candidate.checkpoint(&["a.txt".into()]).await.unwrap();
1004 let entry = write_entry();
1005 candidate
1006 .apply(&write_call("a.txt", "new"), &entry)
1007 .await
1008 .unwrap();
1009 candidate.restore(&before).await.unwrap();
1010 assert_eq!(
1011 std::fs::read_to_string(candidate.overlay_root().join("a.txt")).unwrap(),
1012 "old"
1013 );
1014 assert!(!candidate.has_mutated());
1015 candidate
1016 .apply(&write_call("a.txt", "accepted"), &entry)
1017 .await
1018 .unwrap();
1019 candidate.promote().unwrap();
1020 assert_eq!(
1021 std::fs::read_to_string(source.path().join("a.txt")).unwrap(),
1022 "accepted"
1023 );
1024 }
1025
1026 #[tokio::test]
1027 async fn restore_removes_files_first_created_after_the_checkpoint() {
1028 let source = tempfile::tempdir().unwrap();
1029 std::fs::write(source.path().join("a.txt"), "old").unwrap();
1030 let candidate = CandidateWorkspace::create(source.path(), "n1", 0, "r1").unwrap();
1031 let accepted = candidate.checkpoint(&[]).await.unwrap();
1032 let entry = write_entry();
1033 candidate
1036 .apply(&write_call("fresh.rs", "fn injected() {}"), &entry)
1037 .await
1038 .unwrap();
1039 assert!(candidate.overlay_root().join("fresh.rs").is_file());
1040 assert!(candidate.touched_paths().contains(&"fresh.rs".to_string()));
1041 candidate.restore(&accepted).await.unwrap();
1042 assert!(!candidate.overlay_root().join("fresh.rs").exists());
1043 assert!(candidate.touched_paths().is_empty());
1044 assert!(!candidate.has_mutated());
1045 }
1046
1047 #[tokio::test]
1048 async fn read_paths_are_never_promoted() {
1049 let source = tempfile::tempdir().unwrap();
1050 std::fs::write(source.path().join("read-only.txt"), "user edit kept").unwrap();
1051 let candidate = CandidateWorkspace::create(source.path(), "n1", 0, "r1").unwrap();
1052 candidate
1056 .checkpoint(&["read-only.txt".into()])
1057 .await
1058 .unwrap();
1059 std::fs::write(source.path().join("read-only.txt"), "user edit v2").unwrap();
1060 assert!(candidate.touched_paths().is_empty());
1061 candidate.promote().unwrap();
1062 assert_eq!(
1063 std::fs::read_to_string(source.path().join("read-only.txt")).unwrap(),
1064 "user edit v2"
1065 );
1066 }
1067
1068 #[tokio::test]
1069 async fn backward_compatible_view_restores_old_tests_but_keeps_candidate_code() {
1070 let source = tempfile::tempdir().unwrap();
1071 std::fs::create_dir_all(source.path().join("src")).unwrap();
1072 std::fs::create_dir_all(source.path().join("tests")).unwrap();
1073 std::fs::write(
1074 source.path().join("pyproject.toml"),
1075 "[project]\nname='x'\n",
1076 )
1077 .unwrap();
1078 std::fs::write(source.path().join("src/logic.py"), "VALUE = 'old'\n").unwrap();
1079 std::fs::write(
1080 source.path().join("tests/test_logic.py"),
1081 "def test_value(): assert False\n",
1082 )
1083 .unwrap();
1084 let candidate = CandidateWorkspace::create(source.path(), "n1", 0, "r1").unwrap();
1085 candidate
1086 .apply(
1087 &write_call("src/logic.py", "VALUE = 'candidate'\n"),
1088 &write_entry(),
1089 )
1090 .await
1091 .unwrap();
1092 candidate
1093 .apply(
1094 &write_call("tests/test_logic.py", "def test_value(): pass\n"),
1095 &write_entry(),
1096 )
1097 .await
1098 .unwrap();
1099
1100 let oracle = candidate.backward_compatibility_tests().unwrap().unwrap();
1101 assert_eq!(
1102 std::fs::read_to_string(oracle.root.join("src/logic.py")).unwrap(),
1103 "VALUE = 'candidate'\n"
1104 );
1105 assert_eq!(
1106 std::fs::read_to_string(oracle.root.join("tests/test_logic.py")).unwrap(),
1107 "def test_value(): assert False\n"
1108 );
1109 }
1110
1111 #[tokio::test]
1112 async fn external_oracle_overlays_protected_evidence_onto_candidate_code() {
1113 let source = tempfile::tempdir().unwrap();
1114 std::fs::create_dir_all(source.path().join("src")).unwrap();
1115 std::fs::write(source.path().join("Cargo.toml"), "candidate manifest\n").unwrap();
1116 std::fs::write(source.path().join("src/lib.rs"), "source code\n").unwrap();
1117 let protected = tempfile::tempdir().unwrap();
1118 std::fs::create_dir_all(protected.path().join("tests")).unwrap();
1119 std::fs::write(
1120 protected.path().join("tests/acceptance.rs"),
1121 "protected test\n",
1122 )
1123 .unwrap();
1124 let candidate = CandidateWorkspace::create(source.path(), "n1", 0, "r1").unwrap();
1125 candidate
1126 .apply(
1127 &write_call("src/lib.rs", "candidate code\n"),
1128 &write_entry(),
1129 )
1130 .await
1131 .unwrap();
1132
1133 let oracle = candidate.external_oracle_tests(protected.path()).unwrap();
1134 assert_eq!(
1135 std::fs::read_to_string(oracle.root.join("src/lib.rs")).unwrap(),
1136 "candidate code\n"
1137 );
1138 assert_eq!(
1139 std::fs::read_to_string(oracle.root.join("tests/acceptance.rs")).unwrap(),
1140 "protected test\n"
1141 );
1142 }
1143
1144 #[test]
1145 fn external_oracle_refuses_the_workspace_as_its_overlay() {
1146 let source = tempfile::tempdir().unwrap();
1147 std::fs::write(source.path().join("Cargo.toml"), "candidate manifest\n").unwrap();
1148 let candidate = CandidateWorkspace::create(source.path(), "n1", 0, "r1").unwrap();
1149 let error = candidate.external_oracle_tests(source.path()).unwrap_err();
1150 assert!(error.to_string().contains("dedicated directory"));
1151 }
1152
1153 #[tokio::test]
1154 async fn promotion_refuses_a_concurrent_workspace_edit() {
1155 let source = tempfile::tempdir().unwrap();
1156 std::fs::write(source.path().join("a.txt"), "baseline").unwrap();
1157 let candidate = CandidateWorkspace::create(source.path(), "n1", 0, "r1").unwrap();
1158 candidate
1159 .apply(&write_call("a.txt", "agent edit"), &write_entry())
1160 .await
1161 .unwrap();
1162 std::fs::write(source.path().join("a.txt"), "user edit").unwrap();
1163 assert!(candidate.promote().is_err());
1164 assert_eq!(
1165 std::fs::read_to_string(source.path().join("a.txt")).unwrap(),
1166 "user edit"
1167 );
1168 }
1169
1170 #[cfg(unix)]
1171 #[tokio::test]
1172 async fn promotion_cannot_cross_a_source_workspace_symlink() {
1173 let source = tempfile::tempdir().unwrap();
1174 let outside = tempfile::tempdir().unwrap();
1175 std::os::unix::fs::symlink(outside.path(), source.path().join("linked")).unwrap();
1176 let candidate = CandidateWorkspace::create(source.path(), "n1", 0, "r1").unwrap();
1177 candidate
1178 .apply(
1179 &write_call("linked/escaped.txt", "must stay in overlay"),
1180 &write_entry(),
1181 )
1182 .await
1183 .unwrap();
1184 assert!(candidate.promote().is_err());
1185 assert!(!outside.path().join("escaped.txt").exists());
1186 }
1187
1188 #[cfg(unix)]
1192 #[tokio::test]
1193 async fn ancestor_swapped_for_symlink_after_admission_is_denied() {
1194 let source = tempfile::tempdir().unwrap();
1195 let outside = tempfile::tempdir().unwrap();
1196 std::fs::create_dir(source.path().join("dir")).unwrap();
1197 std::fs::write(source.path().join("dir/a.txt"), "baseline").unwrap();
1198 let candidate = CandidateWorkspace::create(source.path(), "n1", 0, "r1").unwrap();
1199 candidate
1200 .apply(&write_call("dir/a.txt", "mutated"), &write_entry())
1201 .await
1202 .unwrap();
1203
1204 std::fs::write(outside.path().join("a.txt"), "baseline").unwrap();
1205 std::fs::remove_dir_all(source.path().join("dir")).unwrap();
1206 std::os::unix::fs::symlink(outside.path(), source.path().join("dir")).unwrap();
1207
1208 let error = candidate.promote().unwrap_err();
1209 assert!(
1210 error.to_string().contains("descending into dir"),
1211 "expected symlink refusal, got: {error:#}"
1212 );
1213 assert_eq!(
1214 std::fs::read_to_string(outside.path().join("a.txt")).unwrap(),
1215 "baseline"
1216 );
1217 }
1218
1219 #[tokio::test]
1220 async fn resumed_candidate_keeps_the_original_source_precondition() {
1221 let source = tempfile::tempdir().unwrap();
1222 std::fs::write(source.path().join("a.txt"), "baseline").unwrap();
1223 let first = CandidateWorkspace::create(source.path(), "n1", 0, "r1").unwrap();
1224 first
1225 .apply(&write_call("a.txt", "accepted"), &write_entry())
1226 .await
1227 .unwrap();
1228 let seed = first.export_accepted().await.unwrap();
1229
1230 std::fs::write(source.path().join("a.txt"), "user edit while stopped").unwrap();
1231 let resumed = CandidateWorkspace::create(source.path(), "n1", 0, "r2").unwrap();
1232 resumed.restore_exported(&seed).unwrap();
1233 assert!(resumed.promote().is_err());
1234 assert_eq!(
1235 std::fs::read_to_string(source.path().join("a.txt")).unwrap(),
1236 "user edit while stopped"
1237 );
1238 }
1239}