Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,9 @@ objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSRunningApplication"] }
objc2-foundation = { version = "0.3", features = ["NSData"] }

[dev-dependencies]
tempfile.workspace = true
temp-env = { version = "0.3.6", features = ["async_closure"] }

[build-dependencies]
tauri-build.workspace = true
119 changes: 114 additions & 5 deletions app/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,37 @@ pub async fn get_trajectory_summary(pr: String) -> Result<Option<TrajectorySumma
Ok(summary)
}

/// Return the pre-review recap artifacts for a PR, for the Briefing tab (PR 3).
///
/// Reads `recap.mdx` and any `*.excalidraw` scenes from the PR's recap directory
/// ([`config::recap_dir_for_pr`](cockpit_core::config::recap_dir_for_pr)) and
/// attaches the review's persisted `recap_sha` (for the Briefing to compare
/// against `head_sha` and decide freshness). A missing directory/file yields
/// `mdx: None, scenes: []`; oversized scenes are skipped. This is a read: it
/// never mutates any gate or review state.
///
/// The disk read runs on the blocking pool via
/// [`tokio::task::spawn_blocking`]; only the owned dir path and sha (both
/// `Send`) cross the boundary.
#[tauri::command]
pub async fn get_review_recap(
state: State<'_, Arc<AppState>>,
pr: String,
) -> Result<cockpit_core::recap::RecapPayload, CommandError> {
let pr_ref = PrRef::new(&pr);
let review = state.reviews.get(&pr_ref).ok_or_else(|| CommandError {
message: format!("Review not found: {pr}"),
})?;
let sha = review.recap_sha.clone();
let dir = cockpit_core::config::recap_dir_for_pr(&pr_ref)?;
let payload = tokio::task::spawn_blocking(move || cockpit_core::recap::read_recap(&dir, sha))
.await
.map_err(|e| CommandError {
message: format!("recap read task panicked: {e}"),
})?;
Ok(payload)
}

/// Return the full text of a single file on both sides of a review's diff (B4).
///
/// Feeds the diff gate's optional full-file view. The two revisions are resolved
Expand Down Expand Up @@ -956,6 +987,51 @@ fn file_extension(path: &str) -> Option<&str> {
name.rsplit_once('.').map(|(_, ext)| ext)
}

/// Resolve the review-pipeline skills as `(name, description)` pairs.
///
/// Reads the discovered skills and keeps those flagged into the review pipeline
/// ([`Skill::in_review_pipeline`](cockpit_core::skills::Skill::in_review_pipeline)).
/// FAIL-OPEN: any discovery error yields an empty list so the recap is skipped
/// rather than blocking the review (Invariant §0.1). Order follows discovery
/// (deterministic) so the prompt stays stable.
fn resolve_pipeline_skills() -> Vec<(String, String)> {
match cockpit_core::skills::discover_installed_skills() {
Ok(skills) => skills
.into_iter()
.filter(|s| s.in_review_pipeline)
.map(|s| (s.name, s.description))
.collect(),
Err(e) => {
eprintln!("start_pre_review: skill discovery failed (recap skipped): {e}");
Vec::new()
}
}
}

/// Resolve and create the recap directory for `pr_ref`, returning it on success.
///
/// Returns `None` (recap skipped) when the path cannot be resolved or the
/// directory cannot be created — a recap is best-effort and never fails the
/// review.
fn prepare_recap_dir(pr_ref: &PrRef) -> Option<PathBuf> {
match cockpit_core::config::recap_dir_for_pr(pr_ref) {
Ok(dir) => match std::fs::create_dir_all(&dir) {
Ok(()) => Some(dir),
Err(e) => {
eprintln!(
"start_pre_review: create recap dir {} failed: {e}",
dir.display()
);
None
}
},
Err(e) => {
eprintln!("start_pre_review: recap dir path for {pr_ref}: {e}");
None
}
}
}

/// Spawn the advisory read-only pre-pass reviewer for a PR (shared core of B2).
///
/// This is the single spawn path used by both the explicit [`pre_review`]
Expand Down Expand Up @@ -1030,6 +1106,23 @@ pub(crate) async fn start_pre_review(
})?;
let lenses = review_lenses(&signals, &review.diff.raw);

// Resolve the review-pipeline skills and the lens gate to decide whether to
// ask the reviewer for a visual recap. Skill resolution is FAIL-OPEN: any
// discovery error yields an empty list and the recap is simply skipped —
// skills must never block the review (Invariant §0.1).
let pipeline_skills = resolve_pipeline_skills();
let want_recap = !pipeline_skills.is_empty()
&& cockpit_core::diff_signals::should_run_review_skills(&signals, &review.diff.raw);
// When the gate passes, create the recap dir up front so the agent's writes
// land, and pass it into the prompt (its presence is what activates the
// recap section). A path/creation failure degrades to "no recap" rather than
// failing the review.
let recap_dir = if want_recap {
prepare_recap_dir(&pr_ref)
} else {
None
};

let review_input = cockpit_core::prompt::ReviewInput {
title: &review.title,
body: &review.body,
Expand All @@ -1039,18 +1132,28 @@ pub(crate) async fn start_pre_review(
lenses: &lenses,
output_path: Some(&findings_path),
skills: &skills,
pipeline_skills: &pipeline_skills,
recap_dir: recap_dir.as_deref(),
};
let assembled = cockpit_core::prompt::assemble_review_prompt(&review_input);

// Spawn the reviewer (keyed by PR ref so completion ingests the right
// review). On success, attach the agent and clear any stale findings from a
// previous pre-pass — atomically, so the UI never shows old findings under a
// running agent. The gate state is left UNTOUCHED (Invariant 5).
let agent_run = try_spawn_review_agent(state, app_handle, pr, &pr_ref, &worktree, &assembled)
.await
.map_err(|e| CommandError {
message: format!("failed to spawn reviewer agent: {e}"),
})?;
let agent_run = try_spawn_review_agent(
state,
app_handle,
pr,
&pr_ref,
&worktree,
&assembled,
recap_dir.as_deref(),
)
.await
.map_err(|e| CommandError {
message: format!("failed to spawn reviewer agent: {e}"),
})?;
state.reviews.update(&pr_ref, |r| {
r.review_findings.clear();
r.agent = Some(agent_run);
Expand All @@ -1074,6 +1177,7 @@ async fn try_spawn_review_agent(
pr_ref: &PrRef,
worktree: &std::path::Path,
prompt: &cockpit_core::prompt::AssembledPrompt,
recap_dir: Option<&Path>,
) -> Result<cockpit_core::model::AgentRun, String> {
let config = Config::load().map_err(|e| format!("config: {e}"))?;
// The findings file lives outside the worktree; a headless session can't
Expand All @@ -1088,6 +1192,11 @@ async fn try_spawn_review_agent(
.apply_permission_mode(config.agent_permission_mode, Some(&approve_url))
.with_extra_dir(&findings_dir)
.allow_write_under(&findings_dir);
// The recap dir (when the gate opted in) also lives outside the worktree, so
// pre-authorize the agent's recap.mdx / *.excalidraw writes there the same way.
if let Some(dir) = recap_dir {
spawn_config = spawn_config.with_extra_dir(dir).allow_write_under(dir);
}
// Run the advisory pre-review on the configured override model when set (a
// faster/cheaper tier trades reviewer depth for latency); empty falls back
// to the CLI's default. This applies to the read-only reviewer only.
Expand Down
Loading
Loading