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 scripts/ci/architecture-drift.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const rules = {
RAW_EVENT_EMIT: "new event families need an owner and TypeScript wrapper",
HQ_DIRECT_DB: "HQ must use the API rather than the SQLite database",
HQ_RAW_AUTH_FETCH: "only the Node proxy may attach the API token",
DIAGNOSIS_COMMANDS_MOVED: "diagnosis commands belong in diagnosis_cmds.rs",
};

function files(dir = root) {
Expand Down Expand Up @@ -136,6 +137,7 @@ function scan() {
if (path === "src-tauri/src/main.rs") {
const backend = block(source, /impl\s+ControlBackend\s+for\s+ApiBackend/);
add("API_STORE_BLOCK_ON", path, callHits(backend, /tauri::async_runtime::block_on\s*\(/g, /self\.store/));
add("DIAGNOSIS_COMMANDS_MOVED", path, regexHits(source, /fn\s+(?:get_log_path|get_panic_notice|get_reason_catalog|export_diagnosis|reveal_log_path|get_stuck_after_minutes|set_stuck_after_minutes)\s*\(/g));
}
if (path === "src-tauri/src/api.rs" || path.startsWith("src-tauri/src/api/"))
add("ERROR_TEXT_CLASSIFIER", path, regexHits(source, /err(?:or)?\.(?:contains|starts_with)\s*\(/g));
Expand Down
42 changes: 41 additions & 1 deletion scripts/ci/architecture-drift.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -111,3 +111,43 @@ test("a unicode escape char literal does not confuse test mod stripping", () =>
assert.equal(result.status, 1);
assert.equal((result.stderr.match(/RAW_PROCESS_SPAWN/g) ?? []).length, 1);
});

test("rejects diagnosis commands in main.rs", () => {
const root = fixture({ "src-tauri/src/main.rs": "fn get_reason_catalog() { }\n" });
const result = run(root, "check");
assert.equal(result.status, 1);
assert.match(result.stderr, /DIAGNOSIS_COMMANDS_MOVED.*src-tauri\/src\/main\.rs/);
});

test("contract: all 7 diagnosis commands are properly moved to diagnosis_cmds.rs and exported in main.rs", () => {
const repoRoot = process.env.TEST_REPO_ROOT || dirname(dirname(dirname(fileURLToPath(import.meta.url))));
const mainRs = readFileSync(join(repoRoot, "src-tauri/src/main.rs"), "utf8");
let diagnosisRs = "";
try {
diagnosisRs = readFileSync(join(repoRoot, "src-tauri/src/diagnosis_cmds.rs"), "utf8");
} catch (e) {
// missing in older tree, handled below
}

const commands = [
"get_log_path",
"get_panic_notice",
"get_reason_catalog",
"export_diagnosis",
"reveal_log_path",
"get_stuck_after_minutes",
"set_stuck_after_minutes"
];

for (const cmd of commands) {
assert.doesNotMatch(mainRs, new RegExp(`fn\\s+${cmd}\\s*\\(`), `Command ${cmd} should not be defined in main.rs`);
assert.match(diagnosisRs, new RegExp(`#\\[tauri::command\\]\\s*(?:(?:#\\[[^\\]]*\\]|\\/\\/.*|\\/\\*.*?\\*\\/)\\s*)*(?:pub\\s+)?(?:async\\s+)?fn\\s+${cmd}\\s*\\(`), `Command ${cmd} should be exported in diagnosis_cmds.rs`);
}

const handlerMatch = mainRs.match(/generate_handler!\[(.*?)\]/s);
assert.ok(handlerMatch, "generate_handler! not found in main.rs");
const handlerList = handlerMatch[1];
for (const cmd of commands) {
assert.match(handlerList, new RegExp(`\\b${cmd}\\b`), `Command ${cmd} should be listed in generate_handler!`);
}
});
99 changes: 99 additions & 0 deletions src-tauri/src/diagnosis_cmds.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
use std::sync::Arc;
use tauri::{AppHandle, State};

use crate::app_data_dir;
use crate::diagnosis;
use crate::logging;
use crate::providers::KeyVault;
use crate::status::StatusEngine;
use crate::store::Store;
use crate::stuck;
use crate::PanicNotice;

#[tauri::command]
pub fn get_log_path(app: AppHandle) -> Result<String, String> {
let dir = app_data_dir(&app)?;
Ok(logging::log_file(&dir).display().to_string())
}

#[tauri::command]
pub fn get_panic_notice(notice: State<'_, PanicNotice>) -> PanicNotice {
notice.inner().clone()
}

#[tauri::command]
pub fn get_reason_catalog() -> Vec<diagnosis::ReasonExplanation> {
diagnosis::reason_catalog()
}

#[tauri::command]
pub async fn export_diagnosis(
app: AppHandle,
store: State<'_, Store>,
engine: State<'_, Arc<StatusEngine>>,
vault: State<'_, Arc<KeyVault>>,
notice: State<'_, PanicNotice>,
) -> Result<String, String> {
let dir = app_data_dir(&app)?;
let panic = notice.inner().clone();
let (pack, secrets) = diagnosis::collect(
&dir,
store.inner(),
engine.inner().as_ref(),
vault.inner().as_ref(),
&panic,
)
.await?;
Ok(diagnosis::export_json(&pack, &secrets))
}

/// Open the log file in the OS file manager so the user does not have to
/// know `%APPDATA%`. Explorer's `/select,` highlights the file; elsewhere
/// the parent directory opens.
#[tauri::command]
pub fn reveal_log_path(app: AppHandle) -> Result<(), String> {
let dir = app_data_dir(&app)?;
let path = logging::log_file(&dir);
#[cfg(windows)]
{
crate::proc::command("explorer")
.arg(format!("/select,{}", path.display()))
.spawn()
.map_err(|e| format!("failed to open Explorer: {e}"))?;
Ok(())
}
#[cfg(target_os = "macos")]
{
crate::proc::command("open")
.arg("-R")
.arg(&path)
.spawn()
.map_err(|e| format!("failed to reveal the log file: {e}"))?;
Ok(())
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let parent = path.parent().unwrap_or(path.as_path());
crate::proc::command("xdg-open")
.arg(parent)
.spawn()
.map_err(|e| format!("failed to open the log directory: {e}"))?;
Ok(())
}
}

/// After how many quiet minutes a running worker is called stuck, or `null`
/// when the built-in default applies.
#[tauri::command]
pub async fn get_stuck_after_minutes(store: State<'_, Store>) -> Result<Option<u64>, String> {
Ok(stuck::threshold_minutes(&store).await)
}

/// Set (or with `null`, clear) that threshold.
#[tauri::command]
pub async fn set_stuck_after_minutes(
store: State<'_, Store>,
minutes: Option<u64>,
) -> Result<(), String> {
stuck::set_threshold_minutes(&store, minutes).await
}
94 changes: 2 additions & 92 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ mod development_plan;
mod development_plan_access;
mod development_policy;
mod diagnosis;
mod diagnosis_cmds;
mod diff;
mod digest;
mod enhance;
Expand Down Expand Up @@ -117,6 +118,7 @@ mod web_interface;
mod workers;
mod worktree;

use diagnosis_cmds::*;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
Expand Down Expand Up @@ -1951,45 +1953,6 @@ fn parse_stats_range(range: Option<&str>) -> Result<stats::StatsRange, String> {
}
}

// -- P2-H diagnosis (log path, pack, Warum) --------------------------------

#[tauri::command]
fn get_log_path(app: AppHandle) -> Result<String, String> {
let dir = app_data_dir(&app)?;
Ok(logging::log_file(&dir).display().to_string())
}

#[tauri::command]
fn get_panic_notice(notice: State<'_, PanicNotice>) -> PanicNotice {
notice.inner().clone()
}

#[tauri::command]
fn get_reason_catalog() -> Vec<diagnosis::ReasonExplanation> {
diagnosis::reason_catalog()
}

#[tauri::command]
async fn export_diagnosis(
app: AppHandle,
store: State<'_, Store>,
engine: State<'_, Arc<StatusEngine>>,
vault: State<'_, Arc<KeyVault>>,
notice: State<'_, PanicNotice>,
) -> Result<String, String> {
let dir = app_data_dir(&app)?;
let panic = notice.inner().clone();
let (pack, secrets) = diagnosis::collect(
&dir,
store.inner(),
engine.inner().as_ref(),
vault.inner().as_ref(),
&panic,
)
.await?;
Ok(diagnosis::export_json(&pack, &secrets))
}

/// F5 baseline: CPU/RAM/disk plus the OmniRoute token totals. No ceilings.
#[tauri::command]
async fn get_resource_snapshot(
Expand Down Expand Up @@ -2018,59 +1981,6 @@ async fn delete_session_buffers() -> Result<usize, String> {
.map_err(|e| format!("deleting the session buffers did not finish: {e}"))?
}

/// Open the log file in the OS file manager so the user does not have to
/// know `%APPDATA%`. Explorer's `/select,` highlights the file; elsewhere
/// the parent directory opens.
#[tauri::command]
fn reveal_log_path(app: AppHandle) -> Result<(), String> {
let dir = app_data_dir(&app)?;
let path = logging::log_file(&dir);
#[cfg(windows)]
{
crate::proc::command("explorer")
.arg(format!("/select,{}", path.display()))
.spawn()
.map_err(|e| format!("failed to open Explorer: {e}"))?;
Ok(())
}
#[cfg(target_os = "macos")]
{
crate::proc::command("open")
.arg("-R")
.arg(&path)
.spawn()
.map_err(|e| format!("failed to reveal the log file: {e}"))?;
Ok(())
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let parent = path.parent().unwrap_or(path.as_path());
crate::proc::command("xdg-open")
.arg(parent)
.spawn()
.map_err(|e| format!("failed to open the log directory: {e}"))?;
Ok(())
}
}

// -- stuck diagnosis commands (Phase 18) -----------------------------------

/// After how many quiet minutes a running worker is called stuck, or `null`
/// when the built-in default applies.
#[tauri::command]
async fn get_stuck_after_minutes(store: State<'_, Store>) -> Result<Option<u64>, String> {
Ok(stuck::threshold_minutes(&store).await)
}

/// Set (or with `null`, clear) that threshold.
#[tauri::command]
async fn set_stuck_after_minutes(
store: State<'_, Store>,
minutes: Option<u64>,
) -> Result<(), String> {
stuck::set_threshold_minutes(&store, minutes).await
}

// -- provider commands (Phase 7.2) -----------------------------------------

/// Which providers this machine can reach, and what each one is doing.
Expand Down
Loading