-
-
+
Execution Context
+
+
+
+
+
+
+
+
+
+
Ready.
-
Ready.
-
- Scan Output
-
+ Workspace + Favorites
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- Workflow Run Bridge
+ Run Workflow + Debug
Ready.
-
- Live Event Stream
+ Run Context
+
+ Lifecycle
+
+ Attach Instructions
+
+
+
+
+ Live Event Stream + Summary
+ Unified Failure Card
+
+
+
+
+ Run History + Events
+
+
+
+
+ History
+
+ Events
+
+
+
+
diff --git a/src/DevTool.Host.Gui/TauriShell/src-tauri/src/domain/sdt_bridge.rs b/src/DevTool.Host.Gui/TauriShell/src-tauri/src/domain/sdt_bridge.rs
index 9b0292c..225cf48 100644
--- a/src/DevTool.Host.Gui/TauriShell/src-tauri/src/domain/sdt_bridge.rs
+++ b/src/DevTool.Host.Gui/TauriShell/src-tauri/src/domain/sdt_bridge.rs
@@ -1,4 +1,5 @@
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
@@ -40,6 +41,23 @@ pub struct RunStreamStatusEvent {
pub exit_code: Option
,
}
+#[derive(Deserialize)]
+#[allow(dead_code)]
+pub struct BridgeResponseEnvelope {
+ pub id: Option,
+ pub ok: bool,
+ pub result: Option,
+ pub error: Option,
+}
+
+#[derive(Deserialize)]
+#[allow(dead_code)]
+pub struct BridgeErrorEnvelope {
+ pub code: String,
+ pub message: String,
+ pub details: Option,
+}
+
pub fn find_repo_root(start: &Path) -> Option {
let mut current = Some(start);
while let Some(dir) = current {
@@ -171,6 +189,168 @@ pub fn build_workflow_run_attempts(
attempts
}
+pub fn build_debug_run_attempts(
+ profile_id: &str,
+ selected_root: &str,
+ env_profile: Option<&str>,
+ repo_root: &Path,
+) -> Vec {
+ let mut run_args = vec![
+ String::from("debug"),
+ String::from(profile_id),
+ String::from("--json"),
+ String::from("--project-root"),
+ String::from(selected_root),
+ ];
+ if let Some(profile) = env_profile {
+ if !profile.trim().is_empty() {
+ run_args.push(String::from("--env-profile"));
+ run_args.push(String::from(profile));
+ }
+ }
+
+ let mut attempts: Vec = Vec::new();
+ attempts.push(CommandInvocation {
+ program: String::from("sdt"),
+ args: run_args.clone(),
+ working_dir: repo_root.to_path_buf(),
+ });
+
+ if cfg!(windows) {
+ attempts.push(CommandInvocation {
+ program: String::from("sdt.exe"),
+ args: run_args.clone(),
+ working_dir: repo_root.to_path_buf(),
+ });
+ }
+
+ let devtool_project = repo_root.join("DevTool.csproj");
+ if devtool_project.exists() {
+ let mut dotnet_args = vec![
+ String::from("run"),
+ String::from("--project"),
+ devtool_project.to_string_lossy().to_string(),
+ String::from("--"),
+ ];
+ dotnet_args.extend(run_args);
+ attempts.push(CommandInvocation {
+ program: String::from("dotnet"),
+ args: dotnet_args,
+ working_dir: repo_root.to_path_buf(),
+ });
+ }
+
+ attempts
+}
+
+pub fn build_bridge_stdio_attempts(selected_root: &str, repo_root: &Path) -> Vec {
+ let mut attempts: Vec = Vec::new();
+ let base_args = vec![
+ String::from("bridge"),
+ String::from("--stdio"),
+ String::from("--project-root"),
+ String::from(selected_root),
+ ];
+
+ attempts.push(CommandInvocation {
+ program: String::from("sdt"),
+ args: base_args.clone(),
+ working_dir: repo_root.to_path_buf(),
+ });
+
+ if cfg!(windows) {
+ attempts.push(CommandInvocation {
+ program: String::from("sdt.exe"),
+ args: base_args.clone(),
+ working_dir: repo_root.to_path_buf(),
+ });
+ }
+
+ let devtool_project = repo_root.join("DevTool.csproj");
+ if devtool_project.exists() {
+ let mut dotnet_args = vec![
+ String::from("run"),
+ String::from("--project"),
+ devtool_project.to_string_lossy().to_string(),
+ String::from("--"),
+ ];
+ dotnet_args.extend(base_args);
+ attempts.push(CommandInvocation {
+ program: String::from("dotnet"),
+ args: dotnet_args,
+ working_dir: repo_root.to_path_buf(),
+ });
+ }
+
+ attempts
+}
+
+pub fn run_bridge_request(
+ invocation: &CommandInvocation,
+ id: &str,
+ method: &str,
+ params: Value,
+) -> Result {
+ let mut child = Command::new(&invocation.program)
+ .args(&invocation.args)
+ .current_dir(&invocation.working_dir)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .map_err(|err| {
+ format!(
+ "{} (cwd: {}): {}",
+ command_line(&invocation.program, &invocation.args),
+ invocation.working_dir.display(),
+ err
+ )
+ })?;
+
+ let request = json!({
+ "id": id,
+ "method": method,
+ "params": params,
+ })
+ .to_string();
+
+ if let Some(stdin) = child.stdin.as_mut() {
+ use std::io::Write;
+ stdin
+ .write_all(format!("{request}\n").as_bytes())
+ .map_err(|err| format!("failed writing bridge request: {err}"))?;
+ }
+
+ let output = child
+ .wait_with_output()
+ .map_err(|err| format!("failed waiting for bridge process: {err}"))?;
+
+ if !output.status.success() && output.stdout.is_empty() {
+ return Err(format!(
+ "Bridge command failed.\ncommand: {}\nstderr:\n{}",
+ command_line(&invocation.program, &invocation.args),
+ String::from_utf8_lossy(&output.stderr)
+ ));
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let response_line = stdout
+ .lines()
+ .find(|line| !line.trim().is_empty())
+ .ok_or_else(|| format!("Bridge response was empty. stderr:\n{}", String::from_utf8_lossy(&output.stderr)))?;
+ let response: BridgeResponseEnvelope =
+ serde_json::from_str(response_line).map_err(|err| format!("Invalid bridge response JSON: {err}\nraw: {response_line}"))?;
+
+ if !response.ok {
+ let error = response.error.ok_or_else(|| String::from("Bridge returned ok=false without error object."))?;
+ return Err(format!("Bridge error [{}]: {}", error.code, error.message));
+ }
+
+ response
+ .result
+ .ok_or_else(|| String::from("Bridge response missing result payload."))
+}
+
pub fn run_attempt_to_completion(invocation: &CommandInvocation) -> Result {
let output = Command::new(&invocation.program)
.args(&invocation.args)
diff --git a/src/DevTool.Host.Gui/TauriShell/src-tauri/src/lib.rs b/src/DevTool.Host.Gui/TauriShell/src-tauri/src/lib.rs
index ed3b417..ab2d1f4 100644
--- a/src/DevTool.Host.Gui/TauriShell/src-tauri/src/lib.rs
+++ b/src/DevTool.Host.Gui/TauriShell/src-tauri/src/lib.rs
@@ -1,10 +1,12 @@
mod domain;
use domain::sdt_bridge::{
- build_workflow_run_attempts, build_workspace_scan_attempts, find_repo_root,
- run_attempt_to_completion, spawn_streaming, stream_child_output, CommandExecutionPayload,
+ build_bridge_stdio_attempts, build_debug_run_attempts, build_workflow_run_attempts,
+ build_workspace_scan_attempts, find_repo_root, run_attempt_to_completion, run_bridge_request,
+ spawn_streaming, stream_child_output, CommandExecutionPayload,
};
use serde::Deserialize;
+use serde_json::Value;
use std::io::ErrorKind;
#[derive(Deserialize)]
@@ -16,6 +18,15 @@ struct WorkflowRunRequest {
session_id: String,
}
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct DebugRunRequest {
+ profile_id: String,
+ project_root: Option,
+ env_profile: Option,
+ session_id: String,
+}
+
#[tauri::command]
fn workspace_scan(project_root: Option) -> Result {
let cwd = std::env::current_dir().map_err(|err| format!("failed to get current dir: {err}"))?;
@@ -94,11 +105,90 @@ fn run_workflow(
))
}
+#[tauri::command]
+fn run_debug(
+ app: tauri::AppHandle,
+ request: DebugRunRequest,
+) -> Result {
+ let cwd = std::env::current_dir().map_err(|err| format!("failed to get current dir: {err}"))?;
+ let repo_root = find_repo_root(&cwd).unwrap_or(cwd.clone());
+ let selected_root = request
+ .project_root
+ .clone()
+ .unwrap_or_else(|| repo_root.to_string_lossy().to_string());
+
+ let attempts = build_debug_run_attempts(
+ &request.profile_id,
+ &selected_root,
+ request.env_profile.as_deref(),
+ &repo_root,
+ );
+
+ let mut spawn_errors: Vec = Vec::new();
+ for invocation in attempts {
+ match spawn_streaming(&invocation) {
+ Ok(child) => return stream_child_output(&app, &request.session_id, &invocation, child),
+ Err(err) if err.kind() == ErrorKind::NotFound => {
+ spawn_errors.push(format!(
+ "{} (cwd: {}): {}",
+ invocation.program,
+ invocation.working_dir.display(),
+ err
+ ));
+ }
+ Err(err) => {
+ return Err(format!(
+ "{} (cwd: {}): {}",
+ invocation.program,
+ invocation.working_dir.display(),
+ err
+ ));
+ }
+ }
+ }
+
+ Err(format!(
+ "Unable to start debug command bridge. No runnable command found.\n{}",
+ spawn_errors.join("\n")
+ ))
+}
+
+#[tauri::command]
+fn bridge_call(method: String, params: Value, project_root: Option) -> Result {
+ let cwd = std::env::current_dir().map_err(|err| format!("failed to get current dir: {err}"))?;
+ let repo_root = find_repo_root(&cwd).unwrap_or(cwd.clone());
+ let selected_root = project_root.unwrap_or_else(|| repo_root.to_string_lossy().to_string());
+ let attempts = build_bridge_stdio_attempts(&selected_root, &repo_root);
+ let request_id = format!("gui-{}", uuid_like());
+ let mut errors: Vec = Vec::new();
+
+ for invocation in attempts {
+ match run_bridge_request(&invocation, &request_id, &method, params.clone()) {
+ Ok(result) => return Ok(result),
+ Err(err) => errors.push(err),
+ }
+ }
+
+ Err(format!(
+ "Unable to execute bridge method '{method}'.\n{}",
+ errors.join("\n\n")
+ ))
+}
+
+fn uuid_like() -> String {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ let millis = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis();
+ format!("{:x}", millis)
+}
+
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
- .invoke_handler(tauri::generate_handler![workspace_scan, run_workflow])
+ .invoke_handler(tauri::generate_handler![workspace_scan, run_workflow, run_debug, bridge_call])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
diff --git a/src/DevTool.Host.Gui/TauriShell/src/domain/bridge.ts b/src/DevTool.Host.Gui/TauriShell/src/domain/bridge.ts
new file mode 100644
index 0000000..9ffc17f
--- /dev/null
+++ b/src/DevTool.Host.Gui/TauriShell/src/domain/bridge.ts
@@ -0,0 +1,10 @@
+export type BridgeError = {
+ code: string;
+ message: string;
+ details?: unknown;
+};
+
+export type BridgeCallResult = {
+ ok: true;
+ result: T;
+};
diff --git a/src/DevTool.Host.Gui/TauriShell/src/domain/workflow.ts b/src/DevTool.Host.Gui/TauriShell/src/domain/workflow.ts
index 3029e9f..806a3ed 100644
--- a/src/DevTool.Host.Gui/TauriShell/src/domain/workflow.ts
+++ b/src/DevTool.Host.Gui/TauriShell/src/domain/workflow.ts
@@ -5,6 +5,13 @@ export type WorkflowRunRequest = {
sessionId: string;
};
+export type DebugRunRequest = {
+ profileId: string;
+ projectRoot: string | null;
+ envProfile: string | null;
+ sessionId: string;
+};
+
export type WorkflowRunPayload = {
command: string;
workingDirectory: string;
@@ -13,6 +20,32 @@ export type WorkflowRunPayload = {
exitCode: number;
};
+export type RunSummaryFailureCard = {
+ whatFailed: string;
+ why: string;
+ exactFixCommand: string;
+ retryInstruction: string;
+};
+
+export type RunSummaryPayload = {
+ category: string;
+ runId: string;
+ runEventVersion: string;
+ success: boolean;
+ stopReason: string | null;
+ message: string;
+ exitCode: number;
+ lifecycle: {
+ model: string;
+ plan: boolean;
+ probe: boolean;
+ prompt: boolean;
+ execute: boolean;
+ diagnose: boolean;
+ };
+ failure: RunSummaryFailureCard | null;
+};
+
export type RunStreamLineEvent = {
sessionId: string;
stream: "stdout" | "stderr";
@@ -25,3 +58,18 @@ export type RunStreamStatusEvent = {
message: string;
exitCode: number | null;
};
+
+export type RunEventLine = {
+ category: string;
+ type?: string;
+ event_type?: string;
+ message?: string;
+ workflowId?: string | null;
+ stepId?: string | null;
+ success?: boolean | null;
+ exitCode?: number | null;
+ run_id?: string | null;
+ project_root?: string | null;
+ env_profile?: string | null;
+ timestamp_utc?: string | null;
+};
diff --git a/src/DevTool.Host.Gui/TauriShell/src/domain/workspace.ts b/src/DevTool.Host.Gui/TauriShell/src/domain/workspace.ts
index 2d131e2..0eeab6f 100644
--- a/src/DevTool.Host.Gui/TauriShell/src/domain/workspace.ts
+++ b/src/DevTool.Host.Gui/TauriShell/src/domain/workspace.ts
@@ -1,7 +1,103 @@
-export type WorkspaceScanPayload = {
- command: string;
- workingDirectory: string;
- stdout: string;
- stderr: string;
- exitCode: number;
+export type WorkspaceProject = {
+ name: string;
+ description: string;
+ path: string;
+ tags: string[];
+ toolFamilies: string[];
+ disabled: boolean;
+ detectedBy: string | null;
+ lastValidatedUtc: string | null;
+ resolvedRoot: string;
+};
+
+export type WorkspaceFavorite = {
+ projectPath: string;
+ workflowId: string;
+ label: string | null;
+ resolvedProjectRoot: string;
+};
+
+export type WorkspaceCandidate = {
+ rootPath: string;
+ displayName: string;
+ kinds: string[];
+ primaryKind: string;
+ depth: number;
+ hasDevtoolConfig: boolean;
+ suggestedInit: boolean;
+ warnings: string[];
+};
+
+export type WorkspaceGetResult = {
+ workspaceRoot: string;
+ currentProjectRoot: string;
+ configuredProjects: WorkspaceProject[];
+ favorites: WorkspaceFavorite[];
+ knownProjects: WorkspaceCandidate[];
+ candidates: WorkspaceCandidate[];
+ scanStats: Record;
+};
+
+export type RunEventLogFile = {
+ path: string;
+ name: string;
+ lastWriteTime: string;
+ sizeBytes: number;
+};
+
+export type RunHistoryItem = {
+ filePath: string;
+ lastWriteTime: string;
+ category: string;
+ runId: string | null;
+ projectRoot: string | null;
+ envProfile: string | null;
+ targetId: string | null;
+ success: boolean | null;
+ exitCode: number | null;
+ message: string;
+};
+
+export type DoctorCheck = {
+ name: string;
+ status: "Pass" | "Warn" | "Fail";
+ detail: string;
+ fix: string | null;
+};
+
+export type DoctorReport = {
+ checks: DoctorCheck[];
+ hasFailures: boolean;
+ hasWarnings: boolean;
+};
+
+export type EnvProfilesResult = {
+ active: string | null;
+ profiles: Array<{
+ id: string;
+ description: string;
+ inherits: string[];
+ values: Record;
+ }>;
+};
+
+export type EnvResolveResult = {
+ selected: string | null;
+ values: Record;
+};
+
+export type SetupPlanResult = {
+ projectRoot: string;
+ doctor: {
+ failCount: number;
+ warnCount: number;
+ checks: DoctorCheck[];
+ };
+ plan: Array<{
+ id: string;
+ label: string;
+ mode: string;
+ count: number;
+ }>;
+ recommendedChanges: string[];
};
diff --git a/src/DevTool.Host.Gui/TauriShell/src/features/parityShell.ts b/src/DevTool.Host.Gui/TauriShell/src/features/parityShell.ts
new file mode 100644
index 0000000..790776f
--- /dev/null
+++ b/src/DevTool.Host.Gui/TauriShell/src/features/parityShell.ts
@@ -0,0 +1,358 @@
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import type { RunHistoryItem, WorkspaceGetResult } from "../domain/workspace";
+import type {
+ RunEventLine,
+ RunStreamLineEvent,
+ RunStreamStatusEvent,
+ RunSummaryPayload,
+} from "../domain/workflow";
+import {
+ addWorkspaceCandidate,
+ getWorkspace,
+ listEnvProfiles,
+ listEventFiles,
+ listHistory,
+ readEventFile,
+ resolveEnvProfile,
+ runDebug,
+ runDoctor,
+ runWorkflow,
+ setupPlan,
+ toggleFavorite,
+ workspaceScanRaw,
+} from "../services/sdtBridge";
+
+type RunMode = "workflow" | "debug";
+
+function q(selector: string): T {
+ const el = document.querySelector(selector);
+ if (!el) {
+ throw new Error(`Missing element: ${selector}`);
+ }
+ return el;
+}
+
+function createSessionId(): string {
+ return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
+}
+
+function parseSummary(stdout: string): RunSummaryPayload | null {
+ const lines = stdout
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0);
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
+ try {
+ const obj = JSON.parse(lines[i]) as Record;
+ if (typeof obj.runId === "string" && typeof obj.success === "boolean") {
+ return obj as unknown as RunSummaryPayload;
+ }
+ } catch {
+ // Ignore non-JSON lines.
+ }
+ }
+ return null;
+}
+
+function parseEventLine(line: string): RunEventLine | null {
+ try {
+ return JSON.parse(line) as RunEventLine;
+ } catch {
+ return null;
+ }
+}
+
+function statusIcon(success: boolean | null | undefined): string {
+ if (success === true) return "ok";
+ if (success === false) return "fail";
+ return "-";
+}
+
+export async function setupParityShell(): Promise {
+ const projectRootInput = q("#project-root");
+ const envProfileInput = q("#env-profile");
+ const workflowIdInput = q("#workflow-id");
+ const debugProfileInput = q("#debug-profile-id");
+ const scanRawBtn = q("#scan-raw-btn");
+ const refreshWorkspaceBtn = q("#refresh-workspace-btn");
+ const addCandidateBtn = q("#add-candidate-btn");
+ const addInitCandidateBtn = q("#add-init-candidate-btn");
+ const candidatePathInput = q("#candidate-path");
+ const runWorkflowBtn = q("#run-workflow-btn");
+ const runDebugBtn = q("#run-debug-btn");
+ const verboseModeInput = q("#verbose-mode");
+ const toggleFavoriteBtn = q("#toggle-favorite-btn");
+ const favoriteWorkflowInput = q("#favorite-workflow-id");
+ const favoriteLabelInput = q("#favorite-label");
+ const loadHistoryBtn = q("#load-history-btn");
+ const loadEventsBtn = q("#load-events-btn");
+ const loadDoctorBtn = q("#load-doctor-btn");
+ const loadSetupPlanBtn = q("#load-setup-plan-btn");
+ const loadEnvBtn = q("#load-env-btn");
+ const envResolveInput = q("#env-resolve-id");
+
+ const workspaceStatus = q("#workspace-status");
+ const workspaceOutput = q("#workspace-output");
+ const runStatus = q("#run-status");
+ const runOutput = q("#run-output");
+ const runContext = q("#run-context");
+ const lifecycle = q("#lifecycle");
+ const failureCard = q("#failure-card");
+ const historyOutput = q("#history-output");
+ const eventsOutput = q("#events-output");
+ const envOutput = q("#env-output");
+ const diagnosticsOutput = q("#diagnostics-output");
+ const attachHelp = q("#attach-help");
+
+ let activeSessionId: string | null = null;
+ let workspaceCache: WorkspaceGetResult | null = null;
+ let historyCache: RunHistoryItem[] = [];
+
+ const streamUnlisteners: UnlistenFn[] = [];
+ streamUnlisteners.push(
+ await listen("run_stream_line", (event) => {
+ if (!activeSessionId || event.payload.sessionId !== activeSessionId) {
+ return;
+ }
+ const prefix = event.payload.stream === "stderr" ? "ERR" : "OUT";
+ const line = `[${prefix}] ${event.payload.line}`;
+ runOutput.textContent += `${line}\n`;
+
+ const parsed = parseEventLine(event.payload.line);
+ if (parsed?.message) {
+ const eventType = parsed.type ?? parsed.event_type ?? "event";
+ const status = statusIcon(parsed.success);
+ runOutput.textContent += `[RUN] ${eventType} ${status} ${parsed.message}\n`;
+ }
+
+ runOutput.scrollTop = runOutput.scrollHeight;
+ }),
+ );
+ streamUnlisteners.push(
+ await listen("run_stream_status", (event) => {
+ if (!activeSessionId || event.payload.sessionId !== activeSessionId) {
+ return;
+ }
+ runStatus.textContent = `${event.payload.state}: ${event.payload.message}`;
+ runStatus.className =
+ event.payload.state === "completed" && event.payload.exitCode === 0
+ ? "status ok"
+ : "status error";
+ }),
+ );
+ window.addEventListener("beforeunload", () => {
+ for (const stop of streamUnlisteners) {
+ stop();
+ }
+ });
+
+ async function refreshWorkspace(): Promise {
+ const root = projectRootInput.value.trim() || null;
+ workspaceStatus.textContent = "Loading workspace...";
+ workspaceStatus.className = "status ok";
+ workspaceCache = await getWorkspace(root);
+ workspaceOutput.textContent = JSON.stringify(workspaceCache, null, 2);
+ workspaceStatus.textContent = "Workspace loaded.";
+ }
+
+ async function runExecution(mode: RunMode): Promise {
+ activeSessionId = createSessionId();
+ const root = projectRootInput.value.trim() || null;
+ const envProfile = envProfileInput.value.trim() || null;
+ const verbose = verboseModeInput.checked;
+ runOutput.textContent = "";
+ failureCard.textContent = "";
+ attachHelp.textContent = "";
+ runStatus.textContent = `Starting ${mode}...`;
+ runStatus.className = "status ok";
+
+ const startedAt = new Date().toISOString();
+ const target = mode === "workflow" ? workflowIdInput.value.trim() : debugProfileInput.value.trim();
+ runContext.textContent = JSON.stringify(
+ {
+ category: mode,
+ target,
+ projectRoot: root ?? "(auto)",
+ envProfile: envProfile ?? "(none)",
+ startedAtUtc: startedAt,
+ cwd: "(bridge-managed)",
+ },
+ null,
+ 2,
+ );
+
+ try {
+ const payload =
+ mode === "workflow"
+ ? await runWorkflow({
+ workflowId: target,
+ projectRoot: root,
+ envProfile,
+ sessionId: activeSessionId,
+ })
+ : await runDebug({
+ profileId: target,
+ projectRoot: root,
+ envProfile,
+ sessionId: activeSessionId,
+ });
+
+ if (!verbose) {
+ runOutput.textContent += payload.stdout;
+ }
+ if (payload.stderr.trim()) {
+ runOutput.textContent += `\n[stderr]\n${payload.stderr}`;
+ }
+
+ const summary = parseSummary(payload.stdout);
+ if (summary) {
+ lifecycle.textContent = JSON.stringify(summary.lifecycle, null, 2);
+ if (summary.failure) {
+ failureCard.textContent = JSON.stringify(
+ {
+ whatFailed: summary.failure.whatFailed,
+ why: summary.failure.why,
+ exactFixCommand: summary.failure.exactFixCommand,
+ retryAction: summary.failure.retryInstruction,
+ },
+ null,
+ 2,
+ );
+ runStatus.className = "status error";
+ } else {
+ runStatus.className = "status ok";
+ failureCard.textContent = "No failure card (run succeeded).";
+ }
+ }
+
+ if (mode === "debug" && payload.stdout.includes("Attach")) {
+ attachHelp.textContent = "Attach instructions detected in debug output.";
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ runStatus.textContent = `Execution failed: ${message}`;
+ runStatus.className = "status error";
+ failureCard.textContent = JSON.stringify(
+ {
+ whatFailed: `${mode} bridge failed`,
+ why: message,
+ exactFixCommand: "Check sdt path and run command manually.",
+ retryAction: `Retry ${mode} from GUI after fixing the issue.`,
+ },
+ null,
+ 2,
+ );
+ } finally {
+ activeSessionId = null;
+ }
+ }
+
+ scanRawBtn.addEventListener("click", (event) => {
+ event.preventDefault();
+ void (async () => {
+ const payload = await workspaceScanRaw(projectRootInput.value.trim() || null);
+ diagnosticsOutput.textContent = payload.stdout || payload.stderr;
+ })();
+ });
+
+ refreshWorkspaceBtn.addEventListener("click", (event) => {
+ event.preventDefault();
+ void refreshWorkspace();
+ });
+
+ addCandidateBtn.addEventListener("click", (event) => {
+ event.preventDefault();
+ void (async () => {
+ const candidatePath = candidatePathInput.value.trim();
+ if (!candidatePath) return;
+ workspaceCache = await addWorkspaceCandidate(projectRootInput.value.trim() || null, candidatePath, false);
+ workspaceOutput.textContent = JSON.stringify(workspaceCache, null, 2);
+ })();
+ });
+
+ addInitCandidateBtn.addEventListener("click", (event) => {
+ event.preventDefault();
+ void (async () => {
+ const candidatePath = candidatePathInput.value.trim();
+ if (!candidatePath) return;
+ workspaceCache = await addWorkspaceCandidate(projectRootInput.value.trim() || null, candidatePath, true);
+ workspaceOutput.textContent = JSON.stringify(workspaceCache, null, 2);
+ })();
+ });
+
+ runWorkflowBtn.addEventListener("click", (event) => {
+ event.preventDefault();
+ void runExecution("workflow");
+ });
+
+ runDebugBtn.addEventListener("click", (event) => {
+ event.preventDefault();
+ void runExecution("debug");
+ });
+
+ toggleFavoriteBtn.addEventListener("click", (event) => {
+ event.preventDefault();
+ void (async () => {
+ const workflowId = favoriteWorkflowInput.value.trim();
+ if (!workflowId) return;
+ const projectPath = projectRootInput.value.trim() || ".";
+ const label = favoriteLabelInput.value.trim() || null;
+ const favorites = await toggleFavorite(projectRootInput.value.trim() || null, projectPath, workflowId, label);
+ workspaceStatus.textContent = `Favorites: ${favorites.length}`;
+ workspaceStatus.className = "status ok";
+ })();
+ });
+
+ loadHistoryBtn.addEventListener("click", (event) => {
+ event.preventDefault();
+ void (async () => {
+ historyCache = await listHistory(projectRootInput.value.trim() || null, 60);
+ historyOutput.textContent = JSON.stringify(historyCache, null, 2);
+ })();
+ });
+
+ loadEventsBtn.addEventListener("click", (event) => {
+ event.preventDefault();
+ void (async () => {
+ const files = await listEventFiles(projectRootInput.value.trim() || null);
+ if (files.length === 0) {
+ eventsOutput.textContent = "No event files found.";
+ return;
+ }
+ const selected = files[0];
+ const events = await readEventFile(projectRootInput.value.trim() || null, selected.path);
+ eventsOutput.textContent = JSON.stringify({ file: selected, events }, null, 2);
+ })();
+ });
+
+ loadDoctorBtn.addEventListener("click", (event) => {
+ event.preventDefault();
+ void (async () => {
+ const report = await runDoctor(projectRootInput.value.trim() || null);
+ diagnosticsOutput.textContent = JSON.stringify(report, null, 2);
+ })();
+ });
+
+ loadSetupPlanBtn.addEventListener("click", (event) => {
+ event.preventDefault();
+ void (async () => {
+ const plan = await setupPlan(projectRootInput.value.trim() || null);
+ diagnosticsOutput.textContent = JSON.stringify(plan, null, 2);
+ })();
+ });
+
+ loadEnvBtn.addEventListener("click", (event) => {
+ event.preventDefault();
+ void (async () => {
+ const root = projectRootInput.value.trim() || null;
+ const profiles = await listEnvProfiles(root);
+ const resolved = await resolveEnvProfile(root, envResolveInput.value.trim() || null);
+ envOutput.textContent = JSON.stringify({ profiles, resolved }, null, 2);
+ })();
+ });
+
+ if (!projectRootInput.value.trim()) {
+ projectRootInput.value = "";
+ }
+ await refreshWorkspace();
+}
diff --git a/src/DevTool.Host.Gui/TauriShell/src/features/workflowRun.ts b/src/DevTool.Host.Gui/TauriShell/src/features/workflowRun.ts
deleted file mode 100644
index 5d4ee0f..0000000
--- a/src/DevTool.Host.Gui/TauriShell/src/features/workflowRun.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { listen, type UnlistenFn } from "@tauri-apps/api/event";
-import type {
- RunStreamLineEvent,
- RunStreamStatusEvent,
-} from "../domain/workflow";
-import { runWorkflow } from "../services/sdtBridge";
-
-function createSessionId(): string {
- return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
-}
-
-function appendLine(output: HTMLElement, stream: "stdout" | "stderr", line: string): void {
- const prefix = stream === "stderr" ? "ERR" : "OUT";
- output.textContent += `[${prefix}] ${line}\n`;
- output.scrollTop = output.scrollHeight;
-}
-
-export async function setupWorkflowRunFeature(): Promise {
- const runBtn = document.querySelector("#run-btn");
- const workflowInput = document.querySelector("#workflow-id");
- const rootInput = document.querySelector("#run-project-root");
- const envInput = document.querySelector("#env-profile");
- const statusEl = document.querySelector("#run-status");
- const commandEl = document.querySelector("#run-command");
- const outputEl = document.querySelector("#run-output");
-
- if (!runBtn || !workflowInput || !statusEl || !commandEl || !outputEl) {
- return;
- }
-
- let activeSessionId: string | null = null;
-
- const unlistenFns: UnlistenFn[] = [];
- unlistenFns.push(
- await listen("run_stream_line", (event) => {
- if (!activeSessionId || event.payload.sessionId !== activeSessionId) {
- return;
- }
- appendLine(outputEl, event.payload.stream, event.payload.line);
- }),
- );
- unlistenFns.push(
- await listen("run_stream_status", (event) => {
- if (!activeSessionId || event.payload.sessionId !== activeSessionId) {
- return;
- }
- const state = event.payload.state;
- const suffix =
- event.payload.exitCode === null ? "" : ` (exit ${event.payload.exitCode})`;
- statusEl.textContent = `Workflow ${state}.${suffix}`;
- statusEl.className =
- state === "completed" && event.payload.exitCode === 0
- ? "status ok"
- : "status error";
- }),
- );
-
- window.addEventListener("beforeunload", () => {
- for (const stop of unlistenFns) {
- stop();
- }
- });
-
- runBtn.addEventListener("click", (event) => {
- event.preventDefault();
- void (async () => {
- const workflowId = workflowInput.value.trim();
- if (!workflowId) {
- statusEl.textContent = "Workflow ID is required.";
- statusEl.className = "status error";
- return;
- }
-
- activeSessionId = createSessionId();
- statusEl.textContent = "Starting workflow run...";
- statusEl.className = "status ok";
- commandEl.textContent = "";
- outputEl.textContent = "";
-
- try {
- const payload = await runWorkflow({
- workflowId,
- projectRoot: rootInput?.value.trim() || null,
- envProfile: envInput?.value.trim() || null,
- sessionId: activeSessionId,
- });
-
- commandEl.textContent = `${payload.command} (cwd: ${payload.workingDirectory})`;
- if (payload.stderr.trim().length > 0) {
- appendLine(outputEl, "stderr", payload.stderr.trimEnd());
- }
- statusEl.textContent = `Workflow finished with exit code ${payload.exitCode}.`;
- statusEl.className = payload.exitCode === 0 ? "status ok" : "status error";
- } catch (error) {
- statusEl.textContent = "Workflow run failed to start.";
- statusEl.className = "status error";
- outputEl.textContent =
- error instanceof Error ? error.message : String(error);
- } finally {
- activeSessionId = null;
- }
- })();
- });
-}
diff --git a/src/DevTool.Host.Gui/TauriShell/src/features/workspaceScan.ts b/src/DevTool.Host.Gui/TauriShell/src/features/workspaceScan.ts
deleted file mode 100644
index 4cd87b7..0000000
--- a/src/DevTool.Host.Gui/TauriShell/src/features/workspaceScan.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { workspaceScan } from "../services/sdtBridge";
-
-function setStatus(
- statusEl: HTMLElement,
- message: string,
- isError = false,
-): void {
- statusEl.textContent = message;
- statusEl.className = isError ? "status error" : "status ok";
-}
-
-export function setupWorkspaceScanFeature(): void {
- const runBtn = document.querySelector("#scan-btn");
- const rootInput = document.querySelector("#project-root");
- const statusEl = document.querySelector("#scan-status");
- const commandEl = document.querySelector("#scan-command");
- const outputEl = document.querySelector("#scan-output");
-
- if (!runBtn || !statusEl || !commandEl || !outputEl) {
- return;
- }
-
- runBtn.addEventListener("click", (event) => {
- event.preventDefault();
- void (async () => {
- const projectRoot = rootInput?.value.trim();
- setStatus(statusEl, "Running `sdt workspace scan --json`...");
- commandEl.textContent = "";
- outputEl.textContent = "";
-
- try {
- const payload = await workspaceScan(projectRoot ? projectRoot : null);
- commandEl.textContent = `${payload.command} (cwd: ${payload.workingDirectory})`;
- outputEl.textContent = JSON.stringify(JSON.parse(payload.stdout), null, 2);
- setStatus(statusEl, "Workspace scan completed.");
- } catch (error) {
- setStatus(statusEl, "Workspace scan failed.", true);
- outputEl.textContent =
- error instanceof Error ? error.message : String(error);
- }
- })();
- });
-}
diff --git a/src/DevTool.Host.Gui/TauriShell/src/main.ts b/src/DevTool.Host.Gui/TauriShell/src/main.ts
index ae318ba..d629a76 100644
--- a/src/DevTool.Host.Gui/TauriShell/src/main.ts
+++ b/src/DevTool.Host.Gui/TauriShell/src/main.ts
@@ -1,5 +1,3 @@
-import { setupWorkspaceScanFeature } from "./features/workspaceScan";
-import { setupWorkflowRunFeature } from "./features/workflowRun";
+import { setupParityShell } from "./features/parityShell";
-setupWorkspaceScanFeature();
-void setupWorkflowRunFeature();
+void setupParityShell();
diff --git a/src/DevTool.Host.Gui/TauriShell/src/services/sdtBridge.ts b/src/DevTool.Host.Gui/TauriShell/src/services/sdtBridge.ts
index 3fc199b..6e401b9 100644
--- a/src/DevTool.Host.Gui/TauriShell/src/services/sdtBridge.ts
+++ b/src/DevTool.Host.Gui/TauriShell/src/services/sdtBridge.ts
@@ -1,11 +1,24 @@
import { invoke } from "@tauri-apps/api/core";
-import type { WorkspaceScanPayload } from "../domain/workspace";
-import type { WorkflowRunPayload, WorkflowRunRequest } from "../domain/workflow";
+import type {
+ DoctorReport,
+ EnvProfilesResult,
+ EnvResolveResult,
+ RunEventLogFile,
+ RunHistoryItem,
+ SetupPlanResult,
+ WorkspaceFavorite,
+ WorkspaceGetResult,
+} from "../domain/workspace";
+import type {
+ DebugRunRequest,
+ WorkflowRunPayload,
+ WorkflowRunRequest,
+} from "../domain/workflow";
-export async function workspaceScan(
+export async function workspaceScanRaw(
projectRoot: string | null,
-): Promise {
- return invoke("workspace_scan", { projectRoot });
+): Promise {
+ return invoke("workspace_scan", { projectRoot });
}
export async function runWorkflow(
@@ -13,3 +26,99 @@ export async function runWorkflow(
): Promise {
return invoke("run_workflow", { request });
}
+
+export async function runDebug(
+ request: DebugRunRequest,
+): Promise {
+ return invoke("run_debug", { request });
+}
+
+async function bridgeCall(
+ method: string,
+ params: Record = {},
+ projectRoot: string | null = null,
+): Promise {
+ return invoke("bridge_call", { method, params, projectRoot });
+}
+
+export function getWorkspace(
+ projectRoot: string | null,
+): Promise {
+ return bridgeCall("workspace.get", {}, projectRoot);
+}
+
+export function addWorkspaceCandidate(
+ projectRoot: string | null,
+ candidatePath: string,
+ initializeConfig: boolean,
+): Promise {
+ return bridgeCall(
+ "workspace.add",
+ { candidatePath, initializeConfig },
+ projectRoot,
+ );
+}
+
+export function listFavorites(
+ projectRoot: string | null,
+): Promise {
+ return bridgeCall("favorites.list", {}, projectRoot);
+}
+
+export function toggleFavorite(
+ projectRoot: string | null,
+ favoriteProjectPath: string,
+ workflowId: string,
+ label: string | null,
+): Promise {
+ return bridgeCall(
+ "favorites.toggle",
+ { favoriteProjectPath, workflowId, label },
+ projectRoot,
+ );
+}
+
+export function listHistory(
+ projectRoot: string | null,
+ limit: number,
+): Promise {
+ return bridgeCall("history.list", { limit }, projectRoot);
+}
+
+export function listEventFiles(
+ projectRoot: string | null,
+): Promise {
+ return bridgeCall("events.listFiles", {}, projectRoot);
+}
+
+export function readEventFile(
+ projectRoot: string | null,
+ filePath: string,
+): Promise[]> {
+ return bridgeCall[]>("events.readFile", { filePath }, projectRoot);
+}
+
+export function listEnvProfiles(
+ projectRoot: string | null,
+): Promise {
+ return bridgeCall("envProfiles.list", {}, projectRoot);
+}
+
+export function resolveEnvProfile(
+ projectRoot: string | null,
+ envProfile: string | null,
+): Promise {
+ return bridgeCall(
+ "envProfiles.resolve",
+ { envProfile },
+ projectRoot,
+ );
+}
+
+export function runDoctor(projectRoot: string | null): Promise {
+ return bridgeCall("doctor.run", {}, projectRoot);
+}
+
+export function setupPlan(projectRoot: string | null): Promise {
+ return bridgeCall("setup.plan", {}, projectRoot);
+}
diff --git a/tests/DevTool.Tests/ScriptCommonTests.cs b/tests/DevTool.Tests/ScriptCommonTests.cs
index 49dd87c..9ec4b7d 100644
--- a/tests/DevTool.Tests/ScriptCommonTests.cs
+++ b/tests/DevTool.Tests/ScriptCommonTests.cs
@@ -74,6 +74,37 @@ public sealed class ScriptCommonTests
Assert.EndsWith("npm.cmd", output.Trim(), StringComparison.OrdinalIgnoreCase);
}
+ [Fact]
+ public async Task FindNodeAppRoot_PrefersTauriAppOverRootPackageJson()
+ {
+ var root = CreateTempDir("sdt-script-node-root-");
+ await File.WriteAllTextAsync(Path.Combine(root, "package.json"), """
+{
+ "dependencies": {
+ "left-pad": "1.3.0"
+ }
+}
+""");
+
+ var tauriRoot = Path.Combine(root, "src", "DevTool.Host.Gui", "TauriShell");
+ Directory.CreateDirectory(Path.Combine(tauriRoot, "src-tauri"));
+ await File.WriteAllTextAsync(Path.Combine(tauriRoot, "src-tauri", "tauri.conf.json"), "{}");
+ await File.WriteAllTextAsync(Path.Combine(tauriRoot, "package.json"), """
+{
+ "scripts": {
+ "tauri": "tauri",
+ "build": "vite build"
+ }
+}
+""");
+
+ var output = await RunPythonAsync(
+ root,
+ "import pathlib, script_common; print(script_common.find_node_app_root(pathlib.Path(r'" + Escape(root) + "'), None))");
+
+ Assert.Equal(Path.GetFullPath(tauriRoot), output.Trim());
+ }
+
private static async Task RunPythonAsync(
string workingDir,
string script,