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
11 changes: 11 additions & 0 deletions src-tauri/src/config/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,14 @@ pub fn get_config<R: tauri::Runtime>(app: tauri::AppHandle<R>) -> AppResult<AppC
pub fn set_config<R: tauri::Runtime>(app: tauri::AppHandle<R>, config: AppConfig) -> AppResult<()> {
service::save(&app, config)
}

/// Persists the active project selection (#35) without re-validating the whole
/// config — see [`service::set_active_project`]. The frontend calls this on every
/// project switch so the last-viewed project survives a restart.
#[tauri::command]
pub fn set_active_project<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
project_id: String,
) -> AppResult<()> {
service::set_active_project(&app, &project_id)
}
606 changes: 457 additions & 149 deletions src-tauri/src/config/model.rs

Large diffs are not rendered by default.

326 changes: 323 additions & 3 deletions src-tauri/src/config/service.rs

Large diffs are not rendered by default.

68 changes: 54 additions & 14 deletions src-tauri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,17 @@ pub const REVIEW_EVENT: &str = "review:event";
pub enum PrEvent {
/// The retained tracked-PR list (the persisted-retention view, not a raw
/// per-round discovery — a transient miss flips presence rather than dropping
/// a row).
/// a row). `project_id` is the routing key (#35): the frontend keys the PR list
/// it updates by which project this refresh belongs to.
#[serde(rename_all = "camelCase")]
Updated { prs: Vec<TrackedPrView> },
/// A discovery cycle failed; the loop keeps running.
Updated {
project_id: String,
prs: Vec<TrackedPrView>,
},
/// A discovery cycle failed; the loop keeps running. `project_id` scopes the
/// error to the offending project (#35).
#[serde(rename_all = "camelCase")]
Error { message: String },
Error { project_id: String, message: String },
}

/// A single streamed unit of a review session, forwarded to the frontend.
Expand All @@ -42,34 +47,47 @@ pub enum PrEvent {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum ReviewEvent {
/// Incremental assistant message text.
/// Incremental assistant message text. `project_id` is the routing key (#35):
/// the frontend attributes the streamed delta to the owning project's session.
#[serde(rename_all = "camelCase")]
MessageDelta {
project_id: String,
thread_id: String,
item_id: String,
text: String,
},
/// Incremental reasoning text.
#[serde(rename_all = "camelCase")]
ReasoningDelta {
project_id: String,
thread_id: String,
item_id: String,
text: String,
},
/// The review turn ended (`completed` / `interrupted` / `failed`).
#[serde(rename_all = "camelCase")]
TurnCompleted { thread_id: String, status: String },
TurnCompleted {
project_id: String,
thread_id: String,
status: String,
},
/// A session-level error.
#[serde(rename_all = "camelCase")]
Error { thread_id: String, message: String },
Error {
project_id: String,
thread_id: String,
message: String,
},
/// An auto-trigger dispatch-level notice NOT tied to any one session — config
/// invalid, one/more `start_review` failures, or a ledger-write failure during
/// `crate::dispatch::auto_dispatch`. Carries no `threadId`; the frontend
/// surfaces it as an app-level "auto review" notice (the availability banner),
/// not a session stream event. `message` is single-word so no per-variant
/// `rename_all` is needed (the container tag rename still maps the variant name
/// to the camelCase `"dispatchError"`).
DispatchError { message: String },
/// `crate::dispatch::auto_dispatch`. Carries no `threadId` (session-less), but
/// DOES carry `project_id` (#35) so the frontend can scope the app-level "auto
/// review" notice to the offending project. Now that it has >1 field, it needs
/// its own `#[serde(rename_all = "camelCase")]` so `projectId` serializes
/// camelCase (the container tag rename only maps the variant name to the
/// camelCase `"dispatchError"` — it does not propagate to field keys).
#[serde(rename_all = "camelCase")]
DispatchError { project_id: String, message: String },
}

/// Serde wire-shape lock for the `ReviewEvent` discriminated union.
Expand Down Expand Up @@ -111,12 +129,16 @@ mod tests {
#[test]
fn pr_updated_wire_shape_is_camel_case() {
let event = PrEvent::Updated {
project_id: "p1".to_string(),
prs: vec![sample_view()],
};

let v = serde_json::to_value(&event).expect("PrEvent serializes");

assert_eq!(v["kind"], "updated");
// `projectId` routing key present (camelCase); snake_case absent (#35).
assert!(v.get("projectId").is_some());
assert!(v.get("project_id").is_none());
assert!(v.get("prs").is_some());
// The row carries the flattened `PullRequestView` keys plus the retention
// fields — a drift in `TrackedPrView`'s wire shape surfaces here too.
Expand All @@ -136,12 +158,15 @@ mod tests {
#[test]
fn pr_error_wire_shape_is_camel_case() {
let event = PrEvent::Error {
project_id: "p1".to_string(),
message: "boom".to_string(),
};

let v = serde_json::to_value(&event).expect("PrEvent serializes");

assert_eq!(v["kind"], "error");
assert!(v.get("projectId").is_some());
assert!(v.get("project_id").is_none());
assert!(v.get("message").is_some());
}

Expand All @@ -160,6 +185,7 @@ mod tests {
#[test]
fn message_delta_wire_shape_is_camel_case() {
let event = ReviewEvent::MessageDelta {
project_id: "p1".to_string(),
thread_id: "t1".to_string(),
item_id: "i1".to_string(),
text: "hello".to_string(),
Expand All @@ -171,52 +197,63 @@ mod tests {
assert_eq!(v["kind"], "messageDelta");

// camelCase field keys present.
assert!(v.get("projectId").is_some());
assert!(v.get("threadId").is_some());
assert!(v.get("itemId").is_some());
assert!(v.get("text").is_some());

// snake_case forms absent — a rename would surface here.
assert!(v.get("project_id").is_none());
assert!(v.get("thread_id").is_none());
assert!(v.get("item_id").is_none());
}

#[test]
fn reasoning_delta_wire_shape_is_camel_case() {
let event = ReviewEvent::ReasoningDelta {
project_id: "p1".to_string(),
thread_id: "t1".to_string(),
item_id: "i1".to_string(),
text: "why".to_string(),
};
let v = serde_json::to_value(&event).expect("ReviewEvent serializes");
assert_eq!(v["kind"], "reasoningDelta");
assert!(v.get("projectId").is_some());
assert!(v.get("threadId").is_some());
assert!(v.get("itemId").is_some());
assert!(v.get("text").is_some());
assert!(v.get("project_id").is_none());
assert!(v.get("thread_id").is_none());
assert!(v.get("item_id").is_none());
}

#[test]
fn error_event_wire_shape_is_camel_case() {
let event = ReviewEvent::Error {
project_id: "p1".to_string(),
thread_id: "t1".to_string(),
message: "boom".to_string(),
};
let v = serde_json::to_value(&event).expect("ReviewEvent serializes");
assert_eq!(v["kind"], "error");
assert!(v.get("projectId").is_some());
assert!(v.get("threadId").is_some());
assert!(v.get("message").is_some());
assert!(v.get("project_id").is_none());
assert!(v.get("thread_id").is_none());
}

#[test]
fn dispatch_error_wire_shape_is_camel_case_and_session_less() {
let event = ReviewEvent::DispatchError {
project_id: "p1".to_string(),
message: "boom".to_string(),
};
let v = serde_json::to_value(&event).expect("ReviewEvent serializes");
// Variant tag camelCased by the container rule; carries only `message`.
// Variant tag camelCased by the container rule; carries `projectId` + `message`.
assert_eq!(v["kind"], "dispatchError");
assert!(v.get("projectId").is_some());
assert!(v.get("project_id").is_none());
assert!(v.get("message").is_some());
// Session-less: no thread id (a rename / accidental field would surface here,
// and the `src/types.ts` mirror must stay session-less in lockstep).
Expand All @@ -227,6 +264,7 @@ mod tests {
#[test]
fn turn_completed_wire_shape_is_camel_case() {
let event = ReviewEvent::TurnCompleted {
project_id: "p1".to_string(),
thread_id: "t1".to_string(),
status: "completed".to_string(),
};
Expand All @@ -237,10 +275,12 @@ mod tests {
assert_eq!(v["kind"], "turnCompleted");

// camelCase field keys present.
assert!(v.get("projectId").is_some());
assert!(v.get("threadId").is_some());
assert!(v.get("status").is_some());

// snake_case form absent — a rename would surface here.
assert!(v.get("project_id").is_none());
assert!(v.get("thread_id").is_none());
}
}
60 changes: 39 additions & 21 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ pub fn run() {
// [`dispatch::auto_dispatch`] (so adding an engine never edits `dispatch`).
state.scheduler.set_dispatcher(Arc::new({
let app = app.handle().clone();
move |cands| {
move |project_id, cands| {
let app = app.clone();
Box::pin(run_auto_dispatch(app, cands))
Box::pin(run_auto_dispatch(app, project_id, cands))
}
}));
// Install the WEBHOOK trigger's dispatch hook (#9). The webhook is a
Expand All @@ -65,14 +65,17 @@ pub fn run() {
// is what lets `pr::webhook` stay runtime-agnostic (never names AppHandle).
state.webhook.set_dispatcher(Arc::new({
let app = app.handle().clone();
move |cands| {
move |project_id: String, cands| {
let app = app.clone();
Box::pin(async move {
if !pr::scheduler::auto_review_enabled(&app) {
// The webhook handler already routed by repo to the owning
// project (#35); apply that project's autoReview + static/cooldown
// gates before reusing the same per-project run_auto_dispatch.
if !pr::scheduler::auto_review_enabled(&app, &project_id) {
return;
}
let gated = pr::commands::gate_dispatchable(&app, cands);
run_auto_dispatch(app, gated).await;
let gated = pr::commands::gate_dispatchable(&app, &project_id, cands);
run_auto_dispatch(app, project_id, gated).await;
})
}
}));
Expand Down Expand Up @@ -107,6 +110,7 @@ pub fn run() {
review::commands::start_review,
review::commands::stop_review,
review::commands::list_review_sessions,
config::commands::set_active_project,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
Expand Down Expand Up @@ -134,24 +138,27 @@ pub fn run() {
/// comment-only (PR #31 finding F1).
async fn run_auto_dispatch<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
project_id: String,
candidates: Vec<Candidate>,
) {
if candidates.is_empty() {
return;
}

// A bad / hand-edited config must not take the poll loop down: skip the batch,
// logged + surfaced to the UI (a desktop user never sees stderr).
let cfg = match config::service::load_validated(&app) {
Ok(cfg) => cfg,
// Resolve + validate THIS project (#35): a bad / hand-edited project config (e.g. an
// escaped skill path) must not take the poll loop down — skip the batch, logged +
// surfaced to the UI scoped to the project (a desktop user never sees stderr).
// `project_validated` re-runs the skill-path validation before codex attaches it.
let project = match config::service::project_validated(&app, &project_id) {
Ok(p) => p,
Err(e) => {
let msg = format!("配置无效,自动 review 跳过本轮({})", e.message);
eprintln!("auto-dispatch 跳过本轮:{msg}");
emit_dispatch_error(&app, msg);
eprintln!("auto-dispatch 跳过本轮({project_id}):{msg}");
emit_dispatch_error(&app, &project_id, msg);
return;
}
};
let skill_abs = skill_abs_path(&cfg.repo_root, &cfg.skill_rel_path);
let skill_abs = skill_abs_path(&project.repo_root, &project.skill_rel_path);
let state = app.state::<AppState>();
// Respect an explicit user `stop_codex`: a stopped codex is NOT auto-revived by a
// dispatchable PR. Skip this batch silently (same as the autoReview-off skip — no
Expand All @@ -165,24 +172,35 @@ async fn run_auto_dispatch<R: tauri::Runtime>(
codex: &state.codex,
registry: &state.sessions,
codex_bin: review::commands::CODEX_BIN,
repo: &cfg.repo,
repo_root: &cfg.repo_root,
project_id: &project_id,
repo: &project.repo,
repo_root: &project.repo_root,
skill_abs_path: &skill_abs,
};
// The review slice owns "what counts as active"; the pr slice owns the ledger.
let active = state.sessions.active_pairs();
let record = |cands: &[Candidate]| pr::ledger::record_dispatched(&app, cands);
let report = |msg: String| emit_dispatch_error(&app, msg);
// Both are scoped to this project (#35) so a PR number active in one project does
// not gate the same number in another, and dedup writes land in the right partition.
let active = state.sessions.active_pairs(&project_id);
let record = |cands: &[Candidate]| pr::ledger::record_dispatched(&app, &project_id, cands);
let report = |msg: String| emit_dispatch_error(&app, &project_id, msg);
dispatch::auto_dispatch(candidates, &engine, &active, &record, &report).await;
}

/// Emit a session-less [`events::ReviewEvent::DispatchError`] to the review area
/// (the availability banner). Best-effort — a gone window is not an error worth
/// (the availability banner), routed to `project_id` (#35) so the frontend shows it
/// on the right project. Best-effort — a gone window is not an error worth
/// propagating from the poll loop.
fn emit_dispatch_error<R: tauri::Runtime>(app: &tauri::AppHandle<R>, message: String) {
fn emit_dispatch_error<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
project_id: &str,
message: String,
) {
let _ = app.emit(
events::REVIEW_EVENT,
&events::ReviewEvent::DispatchError { message },
&events::ReviewEvent::DispatchError {
project_id: project_id.to_string(),
message,
},
);
}

Expand Down
Loading
Loading