second first push?
Some checks failed
reliability-matrix / ubuntu-latest / .NET tests (push) Failing after 2m46s
reliability-matrix / macos-latest / .NET tests (push) Has been cancelled
reliability-matrix / windows-latest / .NET tests (push) Has been cancelled

This commit is contained in:
stan44 2026-03-01 21:40:14 -06:00
parent 214c52f556
commit 2c5493f249
29 changed files with 1991 additions and 229 deletions

View File

@ -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
View File

@ -12,4 +12,8 @@ __pycache__/
.dotnet_home
.nuget
publish-test/
.sdt/
.pytest_cache
/node_modules/
/src/DevTool.Host.Gui/TauriShell/node_modules/

View File

@ -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>

View File

@ -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" />

View File

@ -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))

View File

@ -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)

View File

@ -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`)

View 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
View 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."
}
]
}

View File

@ -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

View File

@ -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

View 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())

View 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);

View 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);

View 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>

View File

@ -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.

View File

@ -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

View File

@ -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>

View File

@ -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)

View File

@ -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");
}

View File

@ -0,0 +1,10 @@
export type BridgeError = {
code: string;
message: string;
details?: unknown;
};
export type BridgeCallResult<T> = {
ok: true;
result: T;
};

View File

@ -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;
};

View File

@ -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[];
};

View 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();
}

View File

@ -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;
}
})();
});
}

View File

@ -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);
}
})();
});
}

View File

@ -1,5 +1,3 @@
import { setupWorkspaceScanFeature } from "./features/workspaceScan";
import { setupWorkflowRunFeature } from "./features/workflowRun";
import { setupParityShell } from "./features/parityShell";
setupWorkspaceScanFeature();
void setupWorkflowRunFeature();
void setupParityShell();

View File

@ -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);
}

View File

@ -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,