From 2c5493f2492e244df196032330b9dddc25762c82 Mon Sep 17 00:00:00 2001 From: stan44 Date: Sun, 1 Mar 2026 21:40:14 -0600 Subject: [PATCH] second first push? --- .github/workflows/reliability-matrix.yml | 9 +- .gitignore | 4 + DevTool.csproj | 1 + DevTool.slnx | 1 + Program.cs | 36 ++ README.md | 20 +- ROADMAP.md | 6 + docs/gui-bridge-contract.md | 52 +++ docs/gui-tui-parity.json | 70 ++++ scripts/WORKFLOWS.md | 10 + scripts/script_common.py | 73 +++- scripts/verify-workflow-routes.py | 298 +++++++++++++++ src/DevTool.Host.Bridge/BridgeContracts.cs | 20 + src/DevTool.Host.Bridge/BridgeStdioServer.cs | 356 +++++++++++++++++ .../DevTool.Host.Bridge.csproj | 12 + src/DevTool.Host.Gui/README.md | 9 +- src/DevTool.Host.Gui/TauriShell/README.md | 33 +- src/DevTool.Host.Gui/TauriShell/index.html | 105 +++-- .../src-tauri/src/domain/sdt_bridge.rs | 182 ++++++++- .../TauriShell/src-tauri/src/lib.rs | 96 ++++- .../TauriShell/src/domain/bridge.ts | 10 + .../TauriShell/src/domain/workflow.ts | 48 +++ .../TauriShell/src/domain/workspace.ts | 108 +++++- .../TauriShell/src/features/parityShell.ts | 358 ++++++++++++++++++ .../TauriShell/src/features/workflowRun.ts | 104 ----- .../TauriShell/src/features/workspaceScan.ts | 43 --- src/DevTool.Host.Gui/TauriShell/src/main.ts | 6 +- .../TauriShell/src/services/sdtBridge.ts | 119 +++++- tests/DevTool.Tests/ScriptCommonTests.cs | 31 ++ 29 files changed, 1991 insertions(+), 229 deletions(-) create mode 100644 docs/gui-bridge-contract.md create mode 100644 docs/gui-tui-parity.json create mode 100644 scripts/verify-workflow-routes.py create mode 100644 src/DevTool.Host.Bridge/BridgeContracts.cs create mode 100644 src/DevTool.Host.Bridge/BridgeStdioServer.cs create mode 100644 src/DevTool.Host.Bridge/DevTool.Host.Bridge.csproj create mode 100644 src/DevTool.Host.Gui/TauriShell/src/domain/bridge.ts create mode 100644 src/DevTool.Host.Gui/TauriShell/src/features/parityShell.ts delete mode 100644 src/DevTool.Host.Gui/TauriShell/src/features/workflowRun.ts delete mode 100644 src/DevTool.Host.Gui/TauriShell/src/features/workspaceScan.ts diff --git a/.github/workflows/reliability-matrix.yml b/.github/workflows/reliability-matrix.yml index 4d73746..93e61a5 100644 --- a/.github/workflows/reliability-matrix.yml +++ b/.github/workflows/reliability-matrix.yml @@ -24,12 +24,20 @@ jobs: with: dotnet-version: 10.0.x + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Restore run: dotnet restore DevTool.csproj - name: Build run: dotnet build DevTool.csproj -c Release --no-restore + - name: Verify workflow routes (static) + run: python scripts/verify-workflow-routes.py --project-root . + - name: Test run: dotnet test tests/DevTool.Tests/DevTool.Tests.csproj -c Release --no-build --logger "trx;LogFileName=test-results.trx" @@ -55,4 +63,3 @@ jobs: path: | **/test-results.trx artifacts/reliability-${{ matrix.os }}.json - diff --git a/.gitignore b/.gitignore index d148d21..dad2b0a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,8 @@ __pycache__/ .dotnet_home .nuget publish-test/ +.sdt/ +.pytest_cache +/node_modules/ +/src/DevTool.Host.Gui/TauriShell/node_modules/ diff --git a/DevTool.csproj b/DevTool.csproj index 037a0c2..d45041b 100644 --- a/DevTool.csproj +++ b/DevTool.csproj @@ -17,6 +17,7 @@ + diff --git a/DevTool.slnx b/DevTool.slnx index 2d7eab5..d74cbae 100644 --- a/DevTool.slnx +++ b/DevTool.slnx @@ -1,5 +1,6 @@ + diff --git a/Program.cs b/Program.cs index c7f2ccc..afd1005 100644 --- a/Program.cs +++ b/Program.cs @@ -1,5 +1,6 @@ using Sdt.Config; using Sdt.Core; +using Sdt.Bridge; using Sdt.Tui; using Spectre.Console; @@ -9,6 +10,9 @@ try if (TryGetWorkspaceCommand(cliArgs, out var workspaceCommand)) return RunWorkspaceCommand(cliArgs, workspaceCommand); + if (TryGetBridgeCommand(cliArgs, out var bridgeCommand)) + return await RunBridgeCommandAsync(cliArgs, bridgeCommand); + if (TryGetHeadlessCommand(cliArgs, out var headlessKind)) { var exit = await RunHeadlessAsync(cliArgs, headlessKind); @@ -198,6 +202,38 @@ static bool TryGetWorkspaceCommand(IReadOnlyList cliArgs, out string com return false; } +static bool TryGetBridgeCommand(IReadOnlyList cliArgs, out string command) +{ + command = ""; + if (cliArgs.Count < 2) + return false; + + if (!string.Equals(cliArgs[0], "bridge", StringComparison.OrdinalIgnoreCase)) + return false; + + if (string.Equals(cliArgs[1], "--stdio", StringComparison.OrdinalIgnoreCase)) + { + command = "stdio"; + return true; + } + + return false; +} + +static async Task RunBridgeCommandAsync(IReadOnlyList cliArgs, string command) +{ + if (!string.Equals(command, "stdio", StringComparison.OrdinalIgnoreCase)) + return ExitCodeMapper.FromResult(false, ExecutionStopReason.ValidationFailed); + + var options = ParseOptions(cliArgs.Skip(2).ToArray(), out _); + var startDir = options.TryGetValue("--project-root", out var root) && !string.IsNullOrWhiteSpace(root) + ? root + : Directory.GetCurrentDirectory(); + + var server = new BridgeStdioServer(startDir); + return await server.RunAsync(); +} + static int RunWorkspaceCommand(IReadOnlyList cliArgs, string command) { if (!string.Equals(command, "scan", StringComparison.OrdinalIgnoreCase)) diff --git a/README.md b/README.md index 766749b..43a289e 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,12 @@ sdt run --json [--project-root ] [--env-profile ] [--non- sdt debug --json [--project-root ] [--env-profile ] [--non-interactive] ``` +GUI bridge read/manage command: + +```powershell +sdt bridge --stdio [--project-root ] +``` + Workspace inventory scan (GUI/TUI shared discovery contract): ```powershell @@ -199,8 +205,11 @@ Primary Python entrypoints: - Planned GUI stack for current phase: **Tauri-first** - Avalonia is deferred for re-evaluation after first GUI milestone ships on top of the same headless contracts - GUI workspace scaffold: [TauriShell README](/e:/stansshit/csharp/DevTool-master/src/DevTool.Host.Gui/TauriShell/README.md) -- First command bridge shipped in scaffold: `sdt workspace scan --json` -- Second command bridge shipped in scaffold: `sdt run --json` with live stream panel +- Hybrid GUI bridge is active: + - execution: `sdt run/debug --json` + - read/manage: `sdt bridge --stdio` +- Bridge contract doc: [gui-bridge-contract.md](/e:/stansshit/csharp/DevTool-master/docs/gui-bridge-contract.md) +- Parity manifest: [gui-tui-parity.json](/e:/stansshit/csharp/DevTool-master/docs/gui-tui-parity.json) - GUI will consume: - `sdt workspace scan --json` inventory payload - `run/debug --json` summaries @@ -259,6 +268,13 @@ Run Python script smoke checks: python -m py_compile scripts/*.py ``` +Verify workflow route/path resolution: + +```powershell +python scripts/verify-workflow-routes.py --project-root . +python scripts/verify-workflow-routes.py --project-root . --workflow build --workflow tauri --execute --env-profile dev +``` + ## Reliability Matrix - CI matrix workflow: [reliability-matrix.yml](/e:/stansshit/csharp/DevTool-master/.github/workflows/reliability-matrix.yml) diff --git a/ROADMAP.md b/ROADMAP.md index 3a43403..2761c53 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,11 +42,17 @@ - [x] Bootstrap filters node/npm detection to runnable package scripts (avoids dependency-only `package.json` false positives) - [x] Action layer now skips non-applicable stacks (`npm`/`cargo`/`tauri`/`dotnet`) instead of hard-failing - [x] `publish-output.py` now auto-skips non-detected sidecar/web/gateway/tauri stacks in generic repos +- [x] Add workflow route verifier script (`scripts/verify-workflow-routes.py`) for static path checks + optional headless execution replay +- [x] Wire workflow route verifier into CI reliability matrix (`.github/workflows/reliability-matrix.yml`) ## In Progress (next focus) - [ ] Execute full OS matrix verification on Windows/Linux/macOS runners - [ ] Native GUI shell over headless core services (Tauri-first in v1.x; Avalonia re-evaluate later) +- [x] GUI parity phase 1 bridge foundation shipped (`sdt bridge --stdio` + `DevTool.Host.Bridge`) +- [x] GUI debug + failure UX slice shipped (debug run, failure card, run context/lifecycle panel) +- [x] GUI history/events/env/doctor/setup-plan read views shipped via bridge methods +- [ ] GUI workspace/favorites polish (switching UX + quick action ergonomics) still in progress - [x] Create dedicated `src/DevTool.Host.Gui/TauriShell` scaffold to keep GUI work isolated from TUI/core - [x] Bootstrap first Tauri command bridge: `sdt workspace scan --json` - [x] Structural refactor to domain-separated projects under `src/` (`DevTool.Engine`, `DevTool.Runtime`, `DevTool.Host.Tui`) diff --git a/docs/gui-bridge-contract.md b/docs/gui-bridge-contract.md new file mode 100644 index 0000000..99cd515 --- /dev/null +++ b/docs/gui-bridge-contract.md @@ -0,0 +1,52 @@ +# GUI Bridge Contract (`sdt bridge --stdio`) + +The GUI uses a hybrid bridge: + +- execution: `sdt run/debug --json` (streamed) +- read/manage: `sdt bridge --stdio` JSON-RPC over stdio + +## Envelope + +Request (one JSON object per line): + +```json +{ "id": "req-1", "method": "workspace.get", "params": { "projectRoot": "E:\\repo" } } +``` + +Response: + +```json +{ "id": "req-1", "ok": true, "result": { "...": "..." }, "error": null } +``` + +Error response: + +```json +{ + "id": "req-1", + "ok": false, + "result": null, + "error": { "code": "validation_failed", "message": "Missing required parameter 'filePath'." } +} +``` + +## Methods (v1) + +- `workspace.get` +- `workspace.add` (`candidatePath`, `initializeConfig`) +- `favorites.list` +- `favorites.toggle` (`favoriteProjectPath`, `workflowId`, `label`) +- `history.list` (`limit`) +- `events.listFiles` +- `events.readFile` (`filePath`) +- `envProfiles.list` +- `envProfiles.resolve` (`envProfile`) +- `doctor.run` +- `setup.plan` (read-only preview) + +## Determinism Notes + +- Responses are always single-envelope JSON. +- Unknown methods return `method_not_found`. +- Parameter issues return `validation_failed`. +- GUI should tolerate additive fields in `result`. diff --git a/docs/gui-tui-parity.json b/docs/gui-tui-parity.json new file mode 100644 index 0000000..4da39ac --- /dev/null +++ b/docs/gui-tui-parity.json @@ -0,0 +1,70 @@ +{ + "version": "1.0", + "updatedAtUtc": "2026-03-02T01:00:00Z", + "features": [ + { + "id": "workspace.switch_and_candidates", + "tui": true, + "gui": true, + "status": "in_progress", + "owner": "bridge", + "notes": "GUI can load configured + candidate projects and add/add+init candidates through workspace bridge." + }, + { + "id": "workflow.run", + "tui": true, + "gui": true, + "status": "done", + "owner": "gui", + "notes": "GUI runs workflow via headless run bridge with live stream." + }, + { + "id": "debug.run", + "tui": true, + "gui": true, + "status": "done", + "owner": "gui", + "notes": "GUI runs debug profiles via headless debug bridge with live stream." + }, + { + "id": "failure.card", + "tui": true, + "gui": true, + "status": "done", + "owner": "engine", + "notes": "GUI renders failure card fields from headless summary payload." + }, + { + "id": "run.context.lifecycle", + "tui": true, + "gui": true, + "status": "done", + "owner": "gui", + "notes": "GUI shows run context and lifecycle fields from summary/events." + }, + { + "id": "history.events.viewer", + "tui": true, + "gui": true, + "status": "done", + "owner": "bridge", + "notes": "Bridge methods provide history/event file list and event-file read." + }, + { + "id": "favorites.quick_actions", + "tui": true, + "gui": true, + "status": "in_progress", + "owner": "bridge", + "notes": "Bridge exposes favorites list/toggle; richer quick-action UI still pending." + }, + { + "id": "setup_wizard_autofix", + "tui": true, + "gui": false, + "status": "planned", + "owner": "gui", + "notes": "GUI currently exposes doctor + setup plan preview only." + } + ] +} diff --git a/scripts/WORKFLOWS.md b/scripts/WORKFLOWS.md index 67852d2..8e5172c 100644 --- a/scripts/WORKFLOWS.md +++ b/scripts/WORKFLOWS.md @@ -33,6 +33,16 @@ dotnet run --project DevTool.csproj python scripts/migration-gate.py ``` +## 3.1) Verify workflow route resolution (path + optional execution) + +```powershell +# Static route checks only +python scripts/verify-workflow-routes.py --project-root . + +# Static + headless execution checks for selected workflows +python scripts/verify-workflow-routes.py --project-root . --workflow build --workflow tauri --execute --env-profile dev +``` + ## 4) Manage NuGet cache ```powershell diff --git a/scripts/script_common.py b/scripts/script_common.py index 76ac87d..03faa87 100644 --- a/scripts/script_common.py +++ b/scripts/script_common.py @@ -282,26 +282,73 @@ def find_csproj_by_keyword(repo_root: pathlib.Path, keywords: Sequence[str]) -> def find_node_app_root(repo_root: pathlib.Path, preferred: str | None = None) -> pathlib.Path | None: + def _read_package_json(package_json: pathlib.Path) -> dict | None: + if not package_json.exists(): + return None + try: + data = json.loads(package_json.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else None + except Exception: + return None + + def _has_scripts(package_json: pathlib.Path, names: Sequence[str]) -> bool: + data = _read_package_json(package_json) + if not data: + return False + scripts = data.get("scripts") + if not isinstance(scripts, dict): + return False + for name in names: + value = scripts.get(name) + if isinstance(value, str) and value.strip(): + return True + return False + + def _is_tauri_root(candidate_dir: pathlib.Path) -> bool: + return (candidate_dir / "src-tauri" / "tauri.conf.json").exists() or (candidate_dir / "tauri.conf.json").exists() + + def _iter_package_jsons() -> list[pathlib.Path]: + excluded = {"node_modules", "dist", "build", ".git", ".sdt", ".venv", "venv", "bin", "obj"} + found: list[pathlib.Path] = [] + for current_root, dirs, files in os.walk(repo_root): + dirs[:] = [d for d in dirs if d not in excluded] + if "package.json" in files: + found.append(pathlib.Path(current_root) / "package.json") + found.sort(key=lambda p: len(p.parts)) + return found + if preferred: p = (repo_root / preferred).resolve() - if (p / "package.json").exists(): - return p + package_json = p / "package.json" + if package_json.exists(): + # Keep explicit preferred root only when it appears runnable for node workflows. + if _is_tauri_root(p) or _has_scripts(package_json, ("build", "dev", "start", "test", "tauri")): + return p - direct = repo_root / "package.json" - if direct.exists(): - return repo_root + package_files = _iter_package_jsons() + if not package_files: + return None - tauri_candidates = [] - for package_json in repo_root.rglob("package.json"): - d = package_json.parent - if (d / "src-tauri" / "tauri.conf.json").exists(): - tauri_candidates.append(d) + # Strong preference: a tauri app root with tauri config and package.json. + tauri_candidates = [p.parent for p in package_files if _is_tauri_root(p.parent)] if len(tauri_candidates) == 1: return tauri_candidates[0] + if len(tauri_candidates) > 1: + tauri_candidates.sort(key=lambda p: len(p.parts)) + return tauri_candidates[0] - all_candidates = [p.parent for p in repo_root.rglob("package.json")] - if len(all_candidates) == 1: - return all_candidates[0] + runnable_candidates = [ + p.parent for p in package_files if _has_scripts(p, ("build", "dev", "start", "test", "tauri")) + ] + if len(runnable_candidates) == 1: + return runnable_candidates[0] + if len(runnable_candidates) > 1: + runnable_candidates.sort(key=lambda p: len(p.parts)) + return runnable_candidates[0] + + # As a last fallback, return unique package root only. + if len(package_files) == 1: + return package_files[0].parent return None diff --git a/scripts/verify-workflow-routes.py b/scripts/verify-workflow-routes.py new file mode 100644 index 0000000..c03cbc5 --- /dev/null +++ b/scripts/verify-workflow-routes.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +import argparse +import json +import pathlib +import shutil +import subprocess +import sys +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from script_common import resolve_command, resolve_repo_root + + +def load_config(project_root: pathlib.Path) -> dict: + config_path = project_root / "devtool.json" + if not config_path.exists(): + raise FileNotFoundError(f"devtool.json not found at: {config_path}") + return json.loads(config_path.read_text(encoding="utf-8")) + + +def iter_workflows(config: dict, selected: Optional[set[str]]) -> List[dict]: + workflows = config.get("workflows", []) + if not isinstance(workflows, list): + return [] + normalized: List[dict] = [w for w in workflows if isinstance(w, dict) and isinstance(w.get("id"), str)] + if selected: + normalized = [w for w in normalized if w["id"] in selected] + return normalized + + +def is_command_available(command: str) -> bool: + resolved = resolve_command(command) + if pathlib.Path(resolved).is_file(): + return True + return shutil.which(resolved) is not None + + +def resolve_script_arg(project_root: pathlib.Path, working_dir: pathlib.Path, arg: str) -> pathlib.Path: + p = pathlib.Path(arg) + if p.is_absolute(): + return p + a = working_dir / p + if a.exists(): + return a + b = project_root / p + return b + + +def static_check_workflow(project_root: pathlib.Path, workflow: dict) -> dict: + result = { + "workflowId": workflow.get("id"), + "ok": True, + "issues": [], + "steps": [], + } + + for step in workflow.get("steps", []): + if not isinstance(step, dict): + continue + step_id = step.get("id", "") + step_result = {"stepId": step_id, "ok": True, "issues": []} + + working_dir_rel = step.get("workingDir") or "." + working_dir = (project_root / working_dir_rel).resolve() + if not working_dir.exists(): + step_result["ok"] = False + step_result["issues"].append(f"workingDir_not_found:{working_dir}") + + command = step.get("command") + args = step.get("args") or [] + action = step.get("action") + + if isinstance(command, str) and command.strip(): + if not is_command_available(command): + step_result["ok"] = False + step_result["issues"].append(f"command_not_found:{command}") + + if command.lower() in ("python", "py", "python3", "python.exe", "py.exe"): + if args and isinstance(args[0], str) and args[0].endswith(".py"): + script_path = resolve_script_arg(project_root, working_dir, args[0]) + if not script_path.exists(): + step_result["ok"] = False + step_result["issues"].append(f"python_script_not_found:{script_path}") + + if isinstance(action, str) and action.strip(): + # Action-based steps still require workingDir existence for reliable execution. + if not working_dir.exists(): + step_result["ok"] = False + step_result["issues"].append("action_working_dir_not_found") + + if not step_result["ok"]: + result["ok"] = False + result["issues"].extend(step_result["issues"]) + + result["steps"].append(step_result) + + return result + + +def sdt_attempts(repo_root: pathlib.Path) -> List[List[str]]: + attempts: List[List[str]] = [] + attempts.append(["sdt"]) + if sys.platform.startswith("win"): + attempts.append(["sdt.exe"]) + + local_exe = repo_root / ("sdt.exe" if sys.platform.startswith("win") else "sdt") + if local_exe.exists(): + attempts.append([str(local_exe)]) + + devtool_csproj = repo_root / "DevTool.csproj" + if devtool_csproj.exists(): + attempts.append(["dotnet", "run", "--project", str(devtool_csproj), "--"]) + + # Preserve order but dedupe exact attempts. + seen = set() + unique: List[List[str]] = [] + for a in attempts: + key = tuple(a) + if key in seen: + continue + seen.add(key) + unique.append(a) + return unique + + +def try_run_sdt( + repo_root: pathlib.Path, + command_args: Sequence[str], + timeout_seconds: int, +) -> Tuple[Optional[subprocess.CompletedProcess], Optional[str]]: + errors: List[str] = [] + for base in sdt_attempts(repo_root): + cmd = [*base, *command_args] + try: + proc = subprocess.run( + cmd, + cwd=str(repo_root), + text=True, + capture_output=True, + timeout=timeout_seconds, + check=False, + ) + return proc, " ".join(cmd) + except FileNotFoundError: + errors.append(f"not_found:{' '.join(cmd)}") + except subprocess.TimeoutExpired: + errors.append(f"timeout:{' '.join(cmd)}") + return None, "; ".join(errors) if errors else "no_sdt_attempts" + + +def parse_headless_summary(stdout: str) -> Optional[Dict[str, Any]]: + lines = [line.strip() for line in stdout.splitlines() if line.strip()] + for line in reversed(lines): + if not line.startswith("{"): + continue + try: + payload = json.loads(line) + except Exception: + continue + if isinstance(payload, dict) and "runId" in payload and "success" in payload: + return payload + return None + + +def execute_check_workflow( + repo_root: pathlib.Path, + project_root: pathlib.Path, + workflow_id: str, + env_profile: Optional[str], + timeout_seconds: int, +) -> dict: + args = [ + "run", + workflow_id, + "--json", + "--project-root", + str(project_root), + "--non-interactive", + ] + if env_profile: + args.extend(["--env-profile", env_profile]) + + proc, attempted = try_run_sdt(repo_root, args, timeout_seconds) + if proc is None: + return { + "workflowId": workflow_id, + "ok": False, + "attempted": attempted, + "exitCode": None, + "stopReason": "sdt_not_runnable", + "message": attempted, + } + + summary = parse_headless_summary(proc.stdout) + if summary is None: + return { + "workflowId": workflow_id, + "ok": False, + "attempted": attempted, + "exitCode": proc.returncode, + "stopReason": "missing_summary", + "message": (proc.stderr or proc.stdout).strip(), + } + + return { + "workflowId": workflow_id, + "ok": bool(summary.get("success", False)), + "attempted": attempted, + "exitCode": summary.get("exitCode"), + "stopReason": summary.get("stopReason"), + "message": summary.get("message"), + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Verify SDT workflow routes (static path checks + optional headless execution)." + ) + parser.add_argument("--repo-root", default=None, help="Repo root containing DevTool.csproj") + parser.add_argument("--project-root", default=".", help="Project root containing devtool.json") + parser.add_argument("--workflow", action="append", default=[], help="Workflow id to verify (repeatable)") + parser.add_argument("--execute", action="store_true", help="Also run each workflow via `sdt run ... --json`") + parser.add_argument("--env-profile", default=None) + parser.add_argument("--timeout-seconds", type=int, default=600) + parser.add_argument("--output-json", default=None, help="Write full report JSON to file") + args = parser.parse_args() + + repo_root = resolve_repo_root(args.repo_root) + project_root = (repo_root / args.project_root).resolve() if not pathlib.Path(args.project_root).is_absolute() else pathlib.Path(args.project_root).resolve() + selected = set(args.workflow) if args.workflow else None + + config = load_config(project_root) + workflows = iter_workflows(config, selected) + if not workflows: + print("No workflows selected/found.") + return 2 + + static_results = [static_check_workflow(project_root, w) for w in workflows] + execute_results: List[dict] = [] + if args.execute: + for w in workflows: + wid = w["id"] + execute_results.append( + execute_check_workflow( + repo_root=repo_root, + project_root=project_root, + workflow_id=wid, + env_profile=args.env_profile, + timeout_seconds=args.timeout_seconds, + ) + ) + + static_failures = [r for r in static_results if not r["ok"]] + exec_failures = [r for r in execute_results if not r["ok"]] + + report = { + "repoRoot": str(repo_root), + "projectRoot": str(project_root), + "totalWorkflows": len(workflows), + "static": { + "checked": len(static_results), + "failed": len(static_failures), + "results": static_results, + }, + "execute": { + "enabled": args.execute, + "checked": len(execute_results), + "failed": len(exec_failures), + "results": execute_results, + }, + } + + if args.output_json: + out_path = pathlib.Path(args.output_json) + if not out_path.is_absolute(): + out_path = repo_root / out_path + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(report, indent=2), encoding="utf-8") + print(f"Report written: {out_path}") + + print(f"Static checks: {len(static_results)} workflow(s), failures={len(static_failures)}") + if args.execute: + print(f"Execution checks: {len(execute_results)} workflow(s), failures={len(exec_failures)}") + + if static_failures: + print("\nStatic failures:") + for f in static_failures: + print(f"- {f['workflowId']}: {', '.join(f['issues'])}") + + if exec_failures: + print("\nExecution failures:") + for f in exec_failures: + print(f"- {f['workflowId']}: stopReason={f.get('stopReason')} message={f.get('message')}") + + return 1 if static_failures or exec_failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/DevTool.Host.Bridge/BridgeContracts.cs b/src/DevTool.Host.Bridge/BridgeContracts.cs new file mode 100644 index 0000000..cccebfb --- /dev/null +++ b/src/DevTool.Host.Bridge/BridgeContracts.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Sdt.Bridge; + +public sealed record BridgeRequestEnvelope( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("method")] string Method, + [property: JsonPropertyName("params")] JsonElement Params); + +public sealed record BridgeErrorEnvelope( + [property: JsonPropertyName("code")] string Code, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("details")] object? Details = null); + +public sealed record BridgeResponseEnvelope( + [property: JsonPropertyName("id")] string? Id, + [property: JsonPropertyName("ok")] bool Ok, + [property: JsonPropertyName("result")] object? Result = null, + [property: JsonPropertyName("error")] BridgeErrorEnvelope? Error = null); diff --git a/src/DevTool.Host.Bridge/BridgeStdioServer.cs b/src/DevTool.Host.Bridge/BridgeStdioServer.cs new file mode 100644 index 0000000..f81f5d5 --- /dev/null +++ b/src/DevTool.Host.Bridge/BridgeStdioServer.cs @@ -0,0 +1,356 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Sdt.Config; +using Sdt.Core; + +namespace Sdt.Bridge; + +public sealed class BridgeStdioServer +{ + private readonly JsonSerializerOptions _json; + private readonly string? _startupProjectRoot; + private readonly RunEventLogReader _eventReader = new(); + private readonly ConfigDoctorService _doctor = new(new ToolProbeService(), new RequirementResolver()); + private readonly SetupWizardConfigService _setupConfigService = new(new RequirementResolver()); + private readonly ConfigDoctorAutoFixService _doctorFixes = new(); + + public BridgeStdioServer(string? startupProjectRoot = null) + { + _startupProjectRoot = startupProjectRoot; + _json = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + _json.Converters.Add(new JsonStringEnumConverter()); + } + + public async Task RunAsync(CancellationToken cancellationToken = default) + { + while (!cancellationToken.IsCancellationRequested) + { + var line = await Console.In.ReadLineAsync(cancellationToken).ConfigureAwait(false); + if (line is null) + break; + if (string.IsNullOrWhiteSpace(line)) + continue; + + BridgeResponseEnvelope response; + try + { + var request = JsonSerializer.Deserialize(line, _json); + if (request is null || string.IsNullOrWhiteSpace(request.Id) || string.IsNullOrWhiteSpace(request.Method)) + { + response = Error(null, "bad_request", "Invalid bridge request envelope."); + } + else + { + response = await HandleAsync(request, cancellationToken).ConfigureAwait(false); + } + } + catch (JsonException jex) + { + response = Error(null, "bad_json", $"Invalid JSON: {jex.Message}"); + } + catch (Exception ex) + { + response = Error(null, "internal_error", ex.Message); + } + + Console.Out.WriteLine(JsonSerializer.Serialize(response, _json)); + await Console.Out.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + return 0; + } + + private async Task HandleAsync(BridgeRequestEnvelope request, CancellationToken cancellationToken) + { + try + { + return request.Method switch + { + "workspace.get" => Ok(request.Id, HandleWorkspaceGet(request.Params)), + "workspace.add" => Ok(request.Id, HandleWorkspaceAdd(request.Params)), + "favorites.list" => Ok(request.Id, HandleFavoritesList(request.Params)), + "favorites.toggle" => Ok(request.Id, HandleFavoritesToggle(request.Params)), + "history.list" => Ok(request.Id, HandleHistoryList(request.Params)), + "events.listFiles" => Ok(request.Id, HandleEventsListFiles(request.Params)), + "events.readFile" => Ok(request.Id, HandleEventsReadFile(request.Params)), + "envProfiles.list" => Ok(request.Id, HandleEnvProfilesList(request.Params)), + "envProfiles.resolve" => Ok(request.Id, HandleEnvProfilesResolve(request.Params)), + "doctor.run" => Ok(request.Id, await HandleDoctorRunAsync(request.Params, cancellationToken).ConfigureAwait(false)), + "setup.plan" => Ok(request.Id, HandleSetupPlan(request.Params)), + _ => Error(request.Id, "method_not_found", $"Unsupported bridge method: {request.Method}") + }; + } + catch (BridgeValidationException vex) + { + return Error(request.Id, "validation_failed", vex.Message); + } + catch (Exception ex) + { + return Error(request.Id, "method_failed", ex.Message); + } + } + + private object HandleWorkspaceGet(JsonElement @params) + { + var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory(); + var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found."); + var (workspace, workspaceRoot) = loaded; + var currentRoot = ConfigLoader.FindAndLoad(startDir)?.ProjectRoot ?? Path.GetFullPath(startDir); + var inventory = WorkspaceLoader.ScanInventory(workspaceRoot, currentRoot, workspace); + return new + { + workspaceRoot, + currentProjectRoot = currentRoot, + configuredProjects = workspace.Projects.Select(project => new + { + name = project.Name, + description = project.Description, + path = project.Path, + tags = project.Tags, + toolFamilies = project.ToolFamilies, + disabled = project.Disabled, + detectedBy = project.DetectedBy, + lastValidatedUtc = project.LastValidatedUtc, + resolvedRoot = WorkspaceLoader.ResolveProjectRoot(workspaceRoot, project) + }), + favorites = workspace.Favorites.Select(f => new + { + projectPath = f.ProjectPath, + workflowId = f.WorkflowId, + label = f.Label, + resolvedProjectRoot = WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, f) + }), + knownProjects = inventory.KnownProjects, + candidates = inventory.Candidates, + scanStats = inventory.Snapshot.ScanStats + }; + } + + private object HandleWorkspaceAdd(JsonElement @params) + { + var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory(); + var candidatePath = GetRequiredString(@params, "candidatePath"); + var initConfig = GetBool(@params, "initializeConfig") ?? false; + var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found."); + var (workspace, workspaceRoot) = loaded; + + var absoluteCandidate = Path.GetFullPath(candidatePath); + if (!Directory.Exists(absoluteCandidate)) + throw new BridgeValidationException($"Candidate path does not exist: {absoluteCandidate}"); + + if (initConfig && !File.Exists(Path.Combine(absoluteCandidate, "devtool.json"))) + { + var scan = ConfigBootstrapper.Scan(absoluteCandidate); + var generated = ConfigBootstrapper.BuildDefaultConfig(scan); + ConfigBootstrapper.WriteDefaultConfig(scan.ProjectRoot, generated, overwrite: false); + } + + var existing = workspace.Projects.Any(p => + string.Equals( + WorkspaceLoader.ResolveProjectRoot(workspaceRoot, p), + absoluteCandidate, + StringComparison.OrdinalIgnoreCase)); + if (!existing) + { + var relPath = Path.GetRelativePath(workspaceRoot, absoluteCandidate); + workspace.Projects.Add(new WorkspaceProject + { + Name = Path.GetFileName(absoluteCandidate), + Description = initConfig ? "Added via GUI bridge (initialized)." : "Added via GUI bridge.", + Path = relPath, + Tags = [], + ToolFamilies = [], + Disabled = false, + DetectedBy = "inventory", + LastValidatedUtc = DateTimeOffset.UtcNow, + }); + WorkspaceLoader.Save(workspaceRoot, workspace); + } + + return HandleWorkspaceGet(@params); + } + + private object HandleFavoritesList(JsonElement @params) + { + var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory(); + var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found."); + var (workspace, workspaceRoot) = loaded; + return workspace.Favorites.Select(f => new + { + projectPath = f.ProjectPath, + workflowId = f.WorkflowId, + label = f.Label, + resolvedProjectRoot = WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, f) + }).ToList(); + } + + private object HandleFavoritesToggle(JsonElement @params) + { + var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory(); + var projectPath = GetRequiredString(@params, "favoriteProjectPath"); + var workflowId = GetRequiredString(@params, "workflowId"); + var label = GetString(@params, "label"); + + var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found."); + var (workspace, workspaceRoot) = loaded; + var absoluteProject = Path.GetFullPath(projectPath); + var relativeProject = Path.IsPathRooted(projectPath) ? Path.GetRelativePath(workspaceRoot, absoluteProject) : projectPath; + + var existing = workspace.Favorites.FirstOrDefault(f => + string.Equals(WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, f), absoluteProject, StringComparison.OrdinalIgnoreCase) && + string.Equals(f.WorkflowId, workflowId, StringComparison.OrdinalIgnoreCase)); + + if (existing is not null) + { + workspace.Favorites.Remove(existing); + } + else + { + workspace.Favorites.Add(new WorkspaceFavorite + { + ProjectPath = relativeProject, + WorkflowId = workflowId, + Label = string.IsNullOrWhiteSpace(label) ? null : label + }); + } + + WorkspaceLoader.Save(workspaceRoot, workspace); + return HandleFavoritesList(@params); + } + + private object HandleHistoryList(JsonElement @params) + { + var projectRoot = ResolveProjectRootForProjectScopedMethod(@params); + var limit = GetInt(@params, "limit") ?? 50; + return _eventReader.ListRunHistory(projectRoot, Math.Clamp(limit, 1, 500)); + } + + private object HandleEventsListFiles(JsonElement @params) + { + var projectRoot = ResolveProjectRootForProjectScopedMethod(@params); + return _eventReader.ListEventFiles(projectRoot); + } + + private object HandleEventsReadFile(JsonElement @params) + { + var projectRoot = ResolveProjectRootForProjectScopedMethod(@params); + var filePath = GetRequiredString(@params, "filePath"); + var absolute = Path.GetFullPath(filePath); + var eventsRoot = Path.GetFullPath(Path.Combine(projectRoot, ".sdt", "events")); + if (!absolute.StartsWith(eventsRoot, StringComparison.OrdinalIgnoreCase)) + throw new BridgeValidationException("Event file path must be under .sdt/events."); + + return _eventReader.ReadEvents(absolute); + } + + private object HandleEnvProfilesList(JsonElement @params) + { + var loaded = LoadProject(@params); + var envProfiles = loaded.Config.EnvProfiles; + return new + { + active = envProfiles?.Active, + profiles = envProfiles?.Profiles ?? [] + }; + } + + private object HandleEnvProfilesResolve(JsonElement @params) + { + var loaded = LoadProject(@params); + var profileId = GetString(@params, "envProfile"); + var effective = EnvProfileService.ResolveEffectiveEnv(loaded.Config, profileId); + return new + { + selected = string.IsNullOrWhiteSpace(profileId) ? loaded.Config.EnvProfiles?.Active : profileId, + values = effective + }; + } + + private async Task HandleDoctorRunAsync(JsonElement @params, CancellationToken cancellationToken) + { + var loaded = LoadProject(@params); + var report = await _doctor.RunAsync(loaded.Config, loaded.ProjectRoot, cancellationToken).ConfigureAwait(false); + return report; + } + + private object HandleSetupPlan(JsonElement @params) + { + var loaded = LoadProject(@params); + var report = _doctor.RunAsync(loaded.Config, loaded.ProjectRoot).GetAwaiter().GetResult(); + var update = _setupConfigService.ApplyRecommendedDefaults(loaded.Config); + var missingDirs = _doctorFixes.FindMissingWorkingDirectories(loaded.Config, loaded.ProjectRoot); + return new + { + projectRoot = loaded.ProjectRoot, + doctor = new + { + failCount = report.Checks.Count(c => c.Status == DoctorStatus.Fail), + warnCount = report.Checks.Count(c => c.Status == DoctorStatus.Warn), + checks = report.Checks + }, + plan = new[] + { + new { id = "doctor", label = "Run config doctor", mode = "read-only", count = report.Checks.Count }, + new { id = "autofix-dirs", label = "Create missing working directories", mode = "preview", count = missingDirs.Count }, + new { id = "legacy-migration", label = "Migrate legacy targets -> workflows", mode = "preview", count = loaded.Config.Targets.Count > 0 ? 1 : 0 }, + new { id = "recommended-config", label = "Apply recommended config enhancements", mode = "preview", count = update.Changes.Count }, + }, + recommendedChanges = update.Changes + }; + } + + private LoadedProjectConfig LoadProject(JsonElement @params) + { + var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory(); + return ConfigLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No devtool.json found for project."); + } + + private string ResolveProjectRootForProjectScopedMethod(JsonElement @params) + { + var explicitRoot = GetString(@params, "projectRoot"); + if (!string.IsNullOrWhiteSpace(explicitRoot)) + return Path.GetFullPath(explicitRoot); + return LoadProject(@params).ProjectRoot; + } + + private static string? GetString(JsonElement @params, string name) + { + if (@params.ValueKind != JsonValueKind.Object || !@params.TryGetProperty(name, out var prop)) + return null; + return prop.ValueKind == JsonValueKind.String ? prop.GetString() : null; + } + + private static string GetRequiredString(JsonElement @params, string name) + { + var value = GetString(@params, name); + if (string.IsNullOrWhiteSpace(value)) + throw new BridgeValidationException($"Missing required parameter '{name}'."); + return value; + } + + private static bool? GetBool(JsonElement @params, string name) + { + if (@params.ValueKind != JsonValueKind.Object || !@params.TryGetProperty(name, out var prop)) + return null; + return prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False ? prop.GetBoolean() : null; + } + + private static int? GetInt(JsonElement @params, string name) + { + if (@params.ValueKind != JsonValueKind.Object || !@params.TryGetProperty(name, out var prop)) + return null; + return prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var i) ? i : null; + } + + private static BridgeResponseEnvelope Ok(string? id, object result) => + new(id, true, result, null); + + private static BridgeResponseEnvelope Error(string? id, string code, string message, object? details = null) => + new(id, false, null, new BridgeErrorEnvelope(code, message, details)); +} + +public sealed class BridgeValidationException(string message) : Exception(message); diff --git a/src/DevTool.Host.Bridge/DevTool.Host.Bridge.csproj b/src/DevTool.Host.Bridge/DevTool.Host.Bridge.csproj new file mode 100644 index 0000000..7ccbde7 --- /dev/null +++ b/src/DevTool.Host.Bridge/DevTool.Host.Bridge.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + Sdt + + + + + + diff --git a/src/DevTool.Host.Gui/README.md b/src/DevTool.Host.Gui/README.md index b89b864..3cef4bf 100644 --- a/src/DevTool.Host.Gui/README.md +++ b/src/DevTool.Host.Gui/README.md @@ -5,10 +5,9 @@ This directory contains SDT GUI front-end implementations. Current direction: - `TauriShell/` is the active GUI path for v1.x. -- TUI and GUI must both consume the same headless SDT contracts: - - `sdt workspace scan --json` - - `sdt run --json` - - `sdt debug --json` - - `.sdt/events/*.jsonl` run-event stream +- TUI and GUI consume shared SDT contracts: + - execution via `sdt run/debug --json` + - read/manage via `sdt bridge --stdio` + - run-event logs via `.sdt/events/*.jsonl` Do not put orchestration logic in GUI code. Keep execution logic in core/headless services. diff --git a/src/DevTool.Host.Gui/TauriShell/README.md b/src/DevTool.Host.Gui/TauriShell/README.md index 33119c3..4ddc114 100644 --- a/src/DevTool.Host.Gui/TauriShell/README.md +++ b/src/DevTool.Host.Gui/TauriShell/README.md @@ -2,20 +2,21 @@ This is the first real GUI shell scaffold for SDT. -## Implemented Bridge +## Implemented Bridge (Hybrid) -- Frontend button calls Tauri command: `workspace_scan` -- Rust command runs: - - `sdt workspace scan --json` - - fallback: `sdt.exe workspace scan --json` (Windows) - - fallback: `dotnet run --project DevTool.csproj -- workspace scan --json` -- JSON output is rendered in the GUI for inspection. -- Frontend workflow runner calls Tauri command: `run_workflow` -- Rust command runs: - - `sdt run --json` - - fallback: `sdt.exe run --json` (Windows) - - fallback: `dotnet run --project DevTool.csproj -- run --json` -- Live run output is streamed into the event panel via Tauri events (`run_stream_line`, `run_stream_status`). +- Execution path: + - `run_workflow` -> `sdt run --json` + - `run_debug` -> `sdt debug --json` + - live stream via `run_stream_line` + `run_stream_status` +- Read/manage path: + - `bridge_call` -> `sdt bridge --stdio` + - methods: workspace/favorites/history/events/envProfiles/doctor/setup-plan + +Fallback execution order: + +1. `sdt ...` +2. `sdt.exe ...` (Windows) +3. `dotnet run --project DevTool.csproj -- ...` ## Run @@ -29,3 +30,9 @@ npm run tauri dev - This shell is a thin UI over SDT headless contracts. - Orchestration logic remains in core/headless layers, not GUI code. +- Phase 1 parity slices shipped in this shell: + - workflow + debug run + - failure card rendering from summary payload + - run context + lifecycle panel + - workspace load/add/add+init + - run history + events viewer diff --git a/src/DevTool.Host.Gui/TauriShell/index.html b/src/DevTool.Host.Gui/TauriShell/index.html index 07ec7d6..e0a4186 100644 --- a/src/DevTool.Host.Gui/TauriShell/index.html +++ b/src/DevTool.Host.Gui/TauriShell/index.html @@ -11,54 +11,101 @@
-

SDT GUI Shell

-

First bridge command: sdt workspace scan --json

+

SDT GUI Shell (Parity Phase 1)

+

Hybrid bridge: sdt bridge --stdio + sdt run/debug --json

- -
- - +

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

+

+      
+ +
+

Env Profiles + Doctor + Setup Plan

+
+ + +
+
+ + +
+

Environment

+

+        

Diagnostics

+

       
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,