second first push?
This commit is contained in:
parent
214c52f556
commit
2c5493f249
9
.github/workflows/reliability-matrix.yml
vendored
9
.github/workflows/reliability-matrix.yml
vendored
@ -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
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -12,4 +12,8 @@ __pycache__/
|
||||
.dotnet_home
|
||||
.nuget
|
||||
publish-test/
|
||||
.sdt/
|
||||
.pytest_cache
|
||||
/node_modules/
|
||||
/src/DevTool.Host.Gui/TauriShell/node_modules/
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="src\\DevTool.Engine\\DevTool.Engine.csproj" />
|
||||
<ProjectReference Include="src\\DevTool.Runtime\\DevTool.Runtime.csproj" />
|
||||
<ProjectReference Include="src\\DevTool.Host.Bridge\\DevTool.Host.Bridge.csproj" />
|
||||
<ProjectReference Include="src\\DevTool.Host.Tui\\DevTool.Host.Tui.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/DevTool.Host.Bridge/DevTool.Host.Bridge.csproj" />
|
||||
<Project Path="src/DevTool.Engine/DevTool.Engine.csproj" />
|
||||
<Project Path="src/DevTool.Host.Tui/DevTool.Host.Tui.csproj" />
|
||||
<Project Path="src/DevTool.Runtime/DevTool.Runtime.csproj" />
|
||||
|
||||
36
Program.cs
36
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<string> cliArgs, out string com
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool TryGetBridgeCommand(IReadOnlyList<string> 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<int> RunBridgeCommandAsync(IReadOnlyList<string> 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<string> cliArgs, string command)
|
||||
{
|
||||
if (!string.Equals(command, "scan", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
20
README.md
20
README.md
@ -57,6 +57,12 @@ sdt run <workflowId> --json [--project-root <path>] [--env-profile <id>] [--non-
|
||||
sdt debug <profileId> --json [--project-root <path>] [--env-profile <id>] [--non-interactive]
|
||||
```
|
||||
|
||||
GUI bridge read/manage command:
|
||||
|
||||
```powershell
|
||||
sdt bridge --stdio [--project-root <path>]
|
||||
```
|
||||
|
||||
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 <workflowId> --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)
|
||||
|
||||
@ -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`)
|
||||
|
||||
52
docs/gui-bridge-contract.md
Normal file
52
docs/gui-bridge-contract.md
Normal file
@ -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`.
|
||||
70
docs/gui-tui-parity.json
Normal file
70
docs/gui-tui-parity.json
Normal file
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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():
|
||||
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
|
||||
|
||||
|
||||
|
||||
298
scripts/verify-workflow-routes.py
Normal file
298
scripts/verify-workflow-routes.py
Normal file
@ -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", "<unknown>")
|
||||
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())
|
||||
20
src/DevTool.Host.Bridge/BridgeContracts.cs
Normal file
20
src/DevTool.Host.Bridge/BridgeContracts.cs
Normal file
@ -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);
|
||||
356
src/DevTool.Host.Bridge/BridgeStdioServer.cs
Normal file
356
src/DevTool.Host.Bridge/BridgeStdioServer.cs
Normal file
@ -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<int> 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<BridgeRequestEnvelope>(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<BridgeResponseEnvelope> 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<object> 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);
|
||||
12
src/DevTool.Host.Bridge/DevTool.Host.Bridge.csproj
Normal file
12
src/DevTool.Host.Bridge/DevTool.Host.Bridge.csproj
Normal file
@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>Sdt</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\DevTool.Engine\\DevTool.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@ -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 <workflowId> --json`
|
||||
- `sdt debug <profileId> --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.
|
||||
|
||||
@ -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 <workflowId> --json`
|
||||
- fallback: `sdt.exe run <workflowId> --json` (Windows)
|
||||
- fallback: `dotnet run --project DevTool.csproj -- run <workflowId> --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 <workflowId> --json`
|
||||
- `run_debug` -> `sdt debug <profileId> --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
|
||||
|
||||
@ -11,54 +11,101 @@
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header>
|
||||
<h1>SDT GUI Shell</h1>
|
||||
<p>First bridge command: <code>sdt workspace scan --json</code></p>
|
||||
<h1>SDT GUI Shell (Parity Phase 1)</h1>
|
||||
<p>Hybrid bridge: <code>sdt bridge --stdio</code> + <code>sdt run/debug --json</code></p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<label for="project-root">Project Root (optional)</label>
|
||||
<h2>Execution Context</h2>
|
||||
<div class="stack">
|
||||
<label for="project-root">Project Root</label>
|
||||
<input id="project-root" placeholder="E:\stansshit\csharp\DevTool-master" autocomplete="off" />
|
||||
<label for="env-profile">Env Profile</label>
|
||||
<input id="env-profile" placeholder="dev" autocomplete="off" />
|
||||
<div class="row">
|
||||
<input
|
||||
id="project-root"
|
||||
placeholder="E:\stansshit\csharp\DevTool-master"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button id="scan-btn" type="button">Scan Workspace</button>
|
||||
<button id="refresh-workspace-btn" type="button">Load Workspace</button>
|
||||
<button id="scan-raw-btn" type="button">Raw workspace scan</button>
|
||||
</div>
|
||||
<p id="workspace-status" class="status ok">Ready.</p>
|
||||
</div>
|
||||
<p id="scan-status" class="status ok">Ready.</p>
|
||||
<p id="scan-command" class="command"></p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Scan Output</h2>
|
||||
<pre id="scan-output"></pre>
|
||||
<h2>Workspace + Favorites</h2>
|
||||
<div class="stack">
|
||||
<label for="candidate-path">Candidate Path</label>
|
||||
<input id="candidate-path" placeholder="E:\stansshit\csharp\SomeProject" autocomplete="off" />
|
||||
<div class="row">
|
||||
<button id="add-candidate-btn" type="button">Add Candidate</button>
|
||||
<button id="add-init-candidate-btn" type="button">Add + Init devtool.json</button>
|
||||
</div>
|
||||
<label for="favorite-workflow-id">Favorite Workflow Id</label>
|
||||
<input id="favorite-workflow-id" placeholder="build" autocomplete="off" />
|
||||
<label for="favorite-label">Favorite Label (optional)</label>
|
||||
<input id="favorite-label" placeholder="Quick Build" autocomplete="off" />
|
||||
<button id="toggle-favorite-btn" type="button">Toggle Favorite</button>
|
||||
</div>
|
||||
<pre id="workspace-output"></pre>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Workflow Run Bridge</h2>
|
||||
<h2>Run Workflow + Debug</h2>
|
||||
<div class="stack">
|
||||
<label for="workflow-id">Workflow ID</label>
|
||||
<input id="workflow-id" placeholder="build" autocomplete="off" />
|
||||
|
||||
<label for="run-project-root">Project Root (optional)</label>
|
||||
<input
|
||||
id="run-project-root"
|
||||
placeholder="E:\stansshit\csharp\DevTool-master"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<label for="env-profile">Env Profile (optional)</label>
|
||||
<input id="env-profile" placeholder="dev" autocomplete="off" />
|
||||
|
||||
<button id="run-btn" type="button">Run Workflow</button>
|
||||
<label for="debug-profile-id">Debug Profile ID</label>
|
||||
<input id="debug-profile-id" placeholder="dotnet-run" autocomplete="off" />
|
||||
<label><input id="verbose-mode" type="checkbox" /> verbose output</label>
|
||||
<div class="row">
|
||||
<button id="run-workflow-btn" type="button">Run Workflow</button>
|
||||
<button id="run-debug-btn" type="button">Run Debug</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="run-status" class="status ok">Ready.</p>
|
||||
<p id="run-command" class="command"></p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Live Event Stream</h2>
|
||||
<h2>Run Context</h2>
|
||||
<pre id="run-context"></pre>
|
||||
<h3>Lifecycle</h3>
|
||||
<pre id="lifecycle"></pre>
|
||||
<h3>Attach Instructions</h3>
|
||||
<pre id="attach-help"></pre>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Live Event Stream + Summary</h2>
|
||||
<pre id="run-output"></pre>
|
||||
<h3>Unified Failure Card</h3>
|
||||
<pre id="failure-card"></pre>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Run History + Events</h2>
|
||||
<div class="row">
|
||||
<button id="load-history-btn" type="button">Load Run History</button>
|
||||
<button id="load-events-btn" type="button">Load Latest Events File</button>
|
||||
</div>
|
||||
<h3>History</h3>
|
||||
<pre id="history-output"></pre>
|
||||
<h3>Events</h3>
|
||||
<pre id="events-output"></pre>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Env Profiles + Doctor + Setup Plan</h2>
|
||||
<div class="row">
|
||||
<button id="load-doctor-btn" type="button">Run Doctor</button>
|
||||
<button id="load-setup-plan-btn" type="button">Setup Plan (read-only)</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input id="env-resolve-id" placeholder="env profile id (optional)" autocomplete="off" />
|
||||
<button id="load-env-btn" type="button">Load Env Profiles + Resolve</button>
|
||||
</div>
|
||||
<h3>Environment</h3>
|
||||
<pre id="env-output"></pre>
|
||||
<h3>Diagnostics</h3>
|
||||
<pre id="diagnostics-output"></pre>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@ -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<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct BridgeResponseEnvelope {
|
||||
pub id: Option<String>,
|
||||
pub ok: bool,
|
||||
pub result: Option<Value>,
|
||||
pub error: Option<BridgeErrorEnvelope>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct BridgeErrorEnvelope {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub details: Option<Value>,
|
||||
}
|
||||
|
||||
pub fn find_repo_root(start: &Path) -> Option<PathBuf> {
|
||||
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<CommandInvocation> {
|
||||
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<CommandInvocation> = 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<CommandInvocation> {
|
||||
let mut attempts: Vec<CommandInvocation> = 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<Value, String> {
|
||||
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<CommandExecutionPayload, String> {
|
||||
let output = Command::new(&invocation.program)
|
||||
.args(&invocation.args)
|
||||
|
||||
@ -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<String>,
|
||||
env_profile: Option<String>,
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn workspace_scan(project_root: Option<String>) -> Result<CommandExecutionPayload, String> {
|
||||
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<CommandExecutionPayload, String> {
|
||||
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<String> = 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<String>) -> Result<Value, String> {
|
||||
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<String> = 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");
|
||||
}
|
||||
|
||||
10
src/DevTool.Host.Gui/TauriShell/src/domain/bridge.ts
Normal file
10
src/DevTool.Host.Gui/TauriShell/src/domain/bridge.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export type BridgeError = {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
export type BridgeCallResult<T> = {
|
||||
ok: true;
|
||||
result: T;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, string>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type EnvResolveResult = {
|
||||
selected: string | null;
|
||||
values: Record<string, string>;
|
||||
};
|
||||
|
||||
export type SetupPlanResult = {
|
||||
projectRoot: string;
|
||||
doctor: {
|
||||
failCount: number;
|
||||
warnCount: number;
|
||||
checks: DoctorCheck[];
|
||||
};
|
||||
plan: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
mode: string;
|
||||
count: number;
|
||||
}>;
|
||||
recommendedChanges: string[];
|
||||
};
|
||||
|
||||
358
src/DevTool.Host.Gui/TauriShell/src/features/parityShell.ts
Normal file
358
src/DevTool.Host.Gui/TauriShell/src/features/parityShell.ts
Normal file
@ -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<T extends HTMLElement>(selector: string): T {
|
||||
const el = document.querySelector<T>(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<string, unknown>;
|
||||
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<void> {
|
||||
const projectRootInput = q<HTMLInputElement>("#project-root");
|
||||
const envProfileInput = q<HTMLInputElement>("#env-profile");
|
||||
const workflowIdInput = q<HTMLInputElement>("#workflow-id");
|
||||
const debugProfileInput = q<HTMLInputElement>("#debug-profile-id");
|
||||
const scanRawBtn = q<HTMLButtonElement>("#scan-raw-btn");
|
||||
const refreshWorkspaceBtn = q<HTMLButtonElement>("#refresh-workspace-btn");
|
||||
const addCandidateBtn = q<HTMLButtonElement>("#add-candidate-btn");
|
||||
const addInitCandidateBtn = q<HTMLButtonElement>("#add-init-candidate-btn");
|
||||
const candidatePathInput = q<HTMLInputElement>("#candidate-path");
|
||||
const runWorkflowBtn = q<HTMLButtonElement>("#run-workflow-btn");
|
||||
const runDebugBtn = q<HTMLButtonElement>("#run-debug-btn");
|
||||
const verboseModeInput = q<HTMLInputElement>("#verbose-mode");
|
||||
const toggleFavoriteBtn = q<HTMLButtonElement>("#toggle-favorite-btn");
|
||||
const favoriteWorkflowInput = q<HTMLInputElement>("#favorite-workflow-id");
|
||||
const favoriteLabelInput = q<HTMLInputElement>("#favorite-label");
|
||||
const loadHistoryBtn = q<HTMLButtonElement>("#load-history-btn");
|
||||
const loadEventsBtn = q<HTMLButtonElement>("#load-events-btn");
|
||||
const loadDoctorBtn = q<HTMLButtonElement>("#load-doctor-btn");
|
||||
const loadSetupPlanBtn = q<HTMLButtonElement>("#load-setup-plan-btn");
|
||||
const loadEnvBtn = q<HTMLButtonElement>("#load-env-btn");
|
||||
const envResolveInput = q<HTMLInputElement>("#env-resolve-id");
|
||||
|
||||
const workspaceStatus = q<HTMLElement>("#workspace-status");
|
||||
const workspaceOutput = q<HTMLElement>("#workspace-output");
|
||||
const runStatus = q<HTMLElement>("#run-status");
|
||||
const runOutput = q<HTMLElement>("#run-output");
|
||||
const runContext = q<HTMLElement>("#run-context");
|
||||
const lifecycle = q<HTMLElement>("#lifecycle");
|
||||
const failureCard = q<HTMLElement>("#failure-card");
|
||||
const historyOutput = q<HTMLElement>("#history-output");
|
||||
const eventsOutput = q<HTMLElement>("#events-output");
|
||||
const envOutput = q<HTMLElement>("#env-output");
|
||||
const diagnosticsOutput = q<HTMLElement>("#diagnostics-output");
|
||||
const attachHelp = q<HTMLElement>("#attach-help");
|
||||
|
||||
let activeSessionId: string | null = null;
|
||||
let workspaceCache: WorkspaceGetResult | null = null;
|
||||
let historyCache: RunHistoryItem[] = [];
|
||||
|
||||
const streamUnlisteners: UnlistenFn[] = [];
|
||||
streamUnlisteners.push(
|
||||
await listen<RunStreamLineEvent>("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<RunStreamStatusEvent>("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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
@ -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<void> {
|
||||
const runBtn = document.querySelector<HTMLButtonElement>("#run-btn");
|
||||
const workflowInput = document.querySelector<HTMLInputElement>("#workflow-id");
|
||||
const rootInput = document.querySelector<HTMLInputElement>("#run-project-root");
|
||||
const envInput = document.querySelector<HTMLInputElement>("#env-profile");
|
||||
const statusEl = document.querySelector<HTMLElement>("#run-status");
|
||||
const commandEl = document.querySelector<HTMLElement>("#run-command");
|
||||
const outputEl = document.querySelector<HTMLElement>("#run-output");
|
||||
|
||||
if (!runBtn || !workflowInput || !statusEl || !commandEl || !outputEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
let activeSessionId: string | null = null;
|
||||
|
||||
const unlistenFns: UnlistenFn[] = [];
|
||||
unlistenFns.push(
|
||||
await listen<RunStreamLineEvent>("run_stream_line", (event) => {
|
||||
if (!activeSessionId || event.payload.sessionId !== activeSessionId) {
|
||||
return;
|
||||
}
|
||||
appendLine(outputEl, event.payload.stream, event.payload.line);
|
||||
}),
|
||||
);
|
||||
unlistenFns.push(
|
||||
await listen<RunStreamStatusEvent>("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;
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
@ -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<HTMLButtonElement>("#scan-btn");
|
||||
const rootInput = document.querySelector<HTMLInputElement>("#project-root");
|
||||
const statusEl = document.querySelector<HTMLElement>("#scan-status");
|
||||
const commandEl = document.querySelector<HTMLElement>("#scan-command");
|
||||
const outputEl = document.querySelector<HTMLElement>("#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);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
@ -1,5 +1,3 @@
|
||||
import { setupWorkspaceScanFeature } from "./features/workspaceScan";
|
||||
import { setupWorkflowRunFeature } from "./features/workflowRun";
|
||||
import { setupParityShell } from "./features/parityShell";
|
||||
|
||||
setupWorkspaceScanFeature();
|
||||
void setupWorkflowRunFeature();
|
||||
void setupParityShell();
|
||||
|
||||
@ -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<WorkspaceScanPayload> {
|
||||
return invoke<WorkspaceScanPayload>("workspace_scan", { projectRoot });
|
||||
): Promise<WorkflowRunPayload> {
|
||||
return invoke<WorkflowRunPayload>("workspace_scan", { projectRoot });
|
||||
}
|
||||
|
||||
export async function runWorkflow(
|
||||
@ -13,3 +26,99 @@ export async function runWorkflow(
|
||||
): Promise<WorkflowRunPayload> {
|
||||
return invoke<WorkflowRunPayload>("run_workflow", { request });
|
||||
}
|
||||
|
||||
export async function runDebug(
|
||||
request: DebugRunRequest,
|
||||
): Promise<WorkflowRunPayload> {
|
||||
return invoke<WorkflowRunPayload>("run_debug", { request });
|
||||
}
|
||||
|
||||
async function bridgeCall<T>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
projectRoot: string | null = null,
|
||||
): Promise<T> {
|
||||
return invoke<T>("bridge_call", { method, params, projectRoot });
|
||||
}
|
||||
|
||||
export function getWorkspace(
|
||||
projectRoot: string | null,
|
||||
): Promise<WorkspaceGetResult> {
|
||||
return bridgeCall<WorkspaceGetResult>("workspace.get", {}, projectRoot);
|
||||
}
|
||||
|
||||
export function addWorkspaceCandidate(
|
||||
projectRoot: string | null,
|
||||
candidatePath: string,
|
||||
initializeConfig: boolean,
|
||||
): Promise<WorkspaceGetResult> {
|
||||
return bridgeCall<WorkspaceGetResult>(
|
||||
"workspace.add",
|
||||
{ candidatePath, initializeConfig },
|
||||
projectRoot,
|
||||
);
|
||||
}
|
||||
|
||||
export function listFavorites(
|
||||
projectRoot: string | null,
|
||||
): Promise<WorkspaceFavorite[]> {
|
||||
return bridgeCall<WorkspaceFavorite[]>("favorites.list", {}, projectRoot);
|
||||
}
|
||||
|
||||
export function toggleFavorite(
|
||||
projectRoot: string | null,
|
||||
favoriteProjectPath: string,
|
||||
workflowId: string,
|
||||
label: string | null,
|
||||
): Promise<WorkspaceFavorite[]> {
|
||||
return bridgeCall<WorkspaceFavorite[]>(
|
||||
"favorites.toggle",
|
||||
{ favoriteProjectPath, workflowId, label },
|
||||
projectRoot,
|
||||
);
|
||||
}
|
||||
|
||||
export function listHistory(
|
||||
projectRoot: string | null,
|
||||
limit: number,
|
||||
): Promise<RunHistoryItem[]> {
|
||||
return bridgeCall<RunHistoryItem[]>("history.list", { limit }, projectRoot);
|
||||
}
|
||||
|
||||
export function listEventFiles(
|
||||
projectRoot: string | null,
|
||||
): Promise<RunEventLogFile[]> {
|
||||
return bridgeCall<RunEventLogFile[]>("events.listFiles", {}, projectRoot);
|
||||
}
|
||||
|
||||
export function readEventFile(
|
||||
projectRoot: string | null,
|
||||
filePath: string,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
return bridgeCall<Record<string, unknown>[]>("events.readFile", { filePath }, projectRoot);
|
||||
}
|
||||
|
||||
export function listEnvProfiles(
|
||||
projectRoot: string | null,
|
||||
): Promise<EnvProfilesResult> {
|
||||
return bridgeCall<EnvProfilesResult>("envProfiles.list", {}, projectRoot);
|
||||
}
|
||||
|
||||
export function resolveEnvProfile(
|
||||
projectRoot: string | null,
|
||||
envProfile: string | null,
|
||||
): Promise<EnvResolveResult> {
|
||||
return bridgeCall<EnvResolveResult>(
|
||||
"envProfiles.resolve",
|
||||
{ envProfile },
|
||||
projectRoot,
|
||||
);
|
||||
}
|
||||
|
||||
export function runDoctor(projectRoot: string | null): Promise<DoctorReport> {
|
||||
return bridgeCall<DoctorReport>("doctor.run", {}, projectRoot);
|
||||
}
|
||||
|
||||
export function setupPlan(projectRoot: string | null): Promise<SetupPlanResult> {
|
||||
return bridgeCall<SetupPlanResult>("setup.plan", {}, projectRoot);
|
||||
}
|
||||
|
||||
@ -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<string> RunPythonAsync(
|
||||
string workingDir,
|
||||
string script,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user