Skip to main content

perspt_agent/
candidate.rs

1//! Reversible coding candidate overlay and compiler-backed measurement.
2
3use 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
20/// A node-local candidate filesystem. Model mutations never target the source
21/// workspace directly; only [`promote`](Self::promote) copies touched paths.
22pub struct CandidateWorkspace {
23    source_root: PathBuf,
24    overlay_root: PathBuf,
25    _temp: TempDir,
26    overlay_tools: AgentTools,
27    source_tools: AgentTools,
28    /// Checkpoint scope: every path a proposal has *named*, including pure
29    /// reads. Used for state-witness hashing, never for promotion.
30    tracked: Mutex<BTreeSet<String>>,
31    /// Paths an admitted effect actually mutated. Promotion, diffs, and the
32    /// approval file list read this set — a merely-read file must never be
33    /// copied back into the source workspace.
34    mutated_paths: Mutex<BTreeSet<String>>,
35    /// Workspace pre-image at the first mutation of each path. Promotion
36    /// compares against it to avoid overwriting concurrent user edits.
37    source_preimages: Mutex<BTreeMap<String, Option<Vec<u8>>>>,
38    /// Pre-images of every mutation, in order. Restoring a checkpoint replays
39    /// the suffix recorded after it in reverse, so files first touched after
40    /// the checkpoint are restored (or removed) exactly.
41    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    /// Per-stage wall-clock limits for governed verifier processes.
50    verifier_timeouts: crate::verifier::VerifierTimeouts,
51    /// The open execution plane: exact-name handlers this candidate
52    /// dispatches admitted calls through.
53    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    /// Create a candidate while withholding runtime-owned control-plane files
95    /// such as an in-workspace session database.
96    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    /// Replace the execution plane. The composition root uses this to add
137    /// registered tool families beyond the builtins.
138    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    /// Configure per-stage governed verifier timeouts
166    /// (`[verification] stage_timeout_secs` + overrides).
167    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    /// Validate a workspace-relative path exactly as admission does.
176    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    /// The node generation this candidate was created for.
185    pub fn node_generation(&self) -> u32 {
186        self.generation
187    }
188
189    /// Paths mutated by admitted effects — the only promotable set.
190    pub fn touched_paths(&self) -> Vec<String> {
191        self.mutated_paths.lock().unwrap().iter().cloned().collect()
192    }
193
194    /// Diff the realized candidate against the source workspace. This is an
195    /// observation for advisory/conjunctive validators; gating still measures
196    /// the candidate filesystem directly.
197    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    /// Promote only paths mutated by admitted governed proposals. Checks and
215    /// writes share one directory descriptor per target (`crate::promote`),
216    /// so a path swapped underneath the workspace between validation and
217    /// rename is refused rather than followed.
218    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    /// Build the state witness for a scope from the realized overlay: the
278    /// content hash re-reads disk, and the barrier channels are measured over
279    /// the actually-mutated paths, so the barrier clause observes the
280    /// materialized candidate rather than only the declared proposal.
281    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    /// Validate every proposal-named path and, for a mutating effect, record
297    /// pre-images first: restore must be exact even for paths first touched
298    /// after the accepted checkpoint.
299    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    /// Admit paths a durable external command (e.g. a dependency mutation)
325    /// may touch: validate, journal pre-images, and track them, exactly as
326    /// argument-named paths are admitted before a mutating effect.
327    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    /// Mark externally mutated paths promotable. Callers pass only paths
338    /// whose content actually changed.
339    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    /// Read a path from the overlay; `None` when absent.
348    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    /// Rebuild an accepted candidate state from a durable checkpoint's
358    /// exported files (mid-loop resume): each file is journaled and written
359    /// into the overlay, and re-enters the mutated (promotable) set.
360    pub fn restore_exported(&self, files: &[crate::toolloop::SeedFile]) -> Result<()> {
361        self.restore_files(files, true)
362    }
363
364    /// Seed inherited content (a staged predecessor's contribution) without
365    /// marking it promotable: files are journaled and tracked with their
366    /// pre-images (promotion overwrite-protection intact), but they enter
367    /// `mutated_paths` only when this node's own admitted effects touch
368    /// them — so a downstream export is exactly its own work, never a
369    /// re-export of everything it inherited (PSP-10 system 22).
370    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    /// Record the pre-image of a path before a mutating effect touches it.
408    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    /// Replay the journal suffix recorded after the checkpoint, in reverse.
428    /// A file first created after the checkpoint has a `None` pre-image and
429    /// is removed; a file modified after it is rewritten to its exact bytes.
430    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    /// One `(plugin, command, stage)` per plugin selected for an interactive
456    /// verifier tool call. Plugins owning a mutated path are preferred, so a
457    /// mixed-language repository runs the suite the model is actually
458    /// editing; with nothing mutated yet (or only unowned paths) every
459    /// detected plugin runs — matching the final gate's coverage. A
460    /// `run_test` `filter` argument narrows through the plugin's native
461    /// filter form; the acceptance gate never narrows.
462    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    /// Extra environment for governed verifiers. A copied virtualenv is not
534    /// valid at the overlay path, so `uv run --no-sync` is pointed at the
535    /// *source* project's synced environment instead — read-only use, which
536    /// the sandbox enforces (writes outside the overlay are denied).
537    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    /// Build the optional regression view for the explicit
547    /// `backward-compatible` test policy. The candidate suite still runs in
548    /// full; this second view restores recognized pre-existing test files and
549    /// removes newly added test files. It is deliberately not the default:
550    /// an intentional contract change may legitimately supersede old tests.
551    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    /// Build a protected acceptance view for the explicit `external-oracle`
599    /// policy. The supplied directory is copied over a private copy of the
600    /// resulting candidate only at measurement time, so its tests and harness
601    /// can replace candidate-controlled files without entering promotion.
602    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        // Mutation bookkeeping is centralized: only a successful handler run
664        // of a mutating effect marks its admitted named paths promotable.
665        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 position at snapshot time; restore replays everything after it.
715    journal_len: usize,
716    mutations: u32,
717    mutated: BTreeSet<String>,
718}
719
720/// The pre-image of one path recorded immediately before a mutation.
721struct 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
732/// Interactive test filters run under `sh -c`, so only a conservative
733/// identifier alphabet is accepted — anything else is refused with a
734/// model-facing error rather than quoted through.
735fn 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
775/// One staged promotion target: relative path, held parent descriptor,
776/// source pre-image, and overlay content (`None` means deletion).
777type StagedPromotion = (
778    String,
779    crate::promote::TargetDir,
780    Option<Vec<u8>>,
781    Option<Vec<u8>>,
782);
783
784/// Restore already-promoted targets to their pre-images after a failed
785/// promotion, through the same held descriptors. Restore failures are part
786/// of the returned error, never swallowed: a half-promoted workspace must
787/// be visible to the caller.
788fn 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(&current) {
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    /// Fixture with both a Cargo and a uv project marker.
885    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    /// Inherited staged content restores tracked and journaled but never
941    /// promotable; only the node's own admitted mutations export.
942    #[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        // The inherited file is present in the overlay all the same.
963        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        // A file first touched *after* the accepted checkpoint must be gone
1034        // after a gate rejection, on disk and in the promotable set.
1035        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        // The checkpoint scope names the read path; the user then edits the
1053        // source file while the agent merely read it. Promotion must not
1054        // rewrite it from the stale overlay copy.
1055        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    /// The TOCTOU case: the ancestor is a real directory at admission time
1189    /// and becomes a symlink only after the effect was admitted. The
1190    /// descriptor walk at promotion time must still refuse it.
1191    #[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}