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:
|
with:
|
||||||
dotnet-version: 10.0.x
|
dotnet-version: 10.0.x
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore DevTool.csproj
|
run: dotnet restore DevTool.csproj
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build DevTool.csproj -c Release --no-restore
|
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
|
- name: Test
|
||||||
run: dotnet test tests/DevTool.Tests/DevTool.Tests.csproj -c Release --no-build --logger "trx;LogFileName=test-results.trx"
|
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: |
|
path: |
|
||||||
**/test-results.trx
|
**/test-results.trx
|
||||||
artifacts/reliability-${{ matrix.os }}.json
|
artifacts/reliability-${{ matrix.os }}.json
|
||||||
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -12,4 +12,8 @@ __pycache__/
|
|||||||
.dotnet_home
|
.dotnet_home
|
||||||
.nuget
|
.nuget
|
||||||
publish-test/
|
publish-test/
|
||||||
|
.sdt/
|
||||||
|
.pytest_cache
|
||||||
|
/node_modules/
|
||||||
|
/src/DevTool.Host.Gui/TauriShell/node_modules/
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="src\\DevTool.Engine\\DevTool.Engine.csproj" />
|
<ProjectReference Include="src\\DevTool.Engine\\DevTool.Engine.csproj" />
|
||||||
<ProjectReference Include="src\\DevTool.Runtime\\DevTool.Runtime.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" />
|
<ProjectReference Include="src\\DevTool.Host.Tui\\DevTool.Host.Tui.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/src/">
|
<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.Engine/DevTool.Engine.csproj" />
|
||||||
<Project Path="src/DevTool.Host.Tui/DevTool.Host.Tui.csproj" />
|
<Project Path="src/DevTool.Host.Tui/DevTool.Host.Tui.csproj" />
|
||||||
<Project Path="src/DevTool.Runtime/DevTool.Runtime.csproj" />
|
<Project Path="src/DevTool.Runtime/DevTool.Runtime.csproj" />
|
||||||
|
|||||||
36
Program.cs
36
Program.cs
@ -1,5 +1,6 @@
|
|||||||
using Sdt.Config;
|
using Sdt.Config;
|
||||||
using Sdt.Core;
|
using Sdt.Core;
|
||||||
|
using Sdt.Bridge;
|
||||||
using Sdt.Tui;
|
using Sdt.Tui;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
@ -9,6 +10,9 @@ try
|
|||||||
if (TryGetWorkspaceCommand(cliArgs, out var workspaceCommand))
|
if (TryGetWorkspaceCommand(cliArgs, out var workspaceCommand))
|
||||||
return RunWorkspaceCommand(cliArgs, workspaceCommand);
|
return RunWorkspaceCommand(cliArgs, workspaceCommand);
|
||||||
|
|
||||||
|
if (TryGetBridgeCommand(cliArgs, out var bridgeCommand))
|
||||||
|
return await RunBridgeCommandAsync(cliArgs, bridgeCommand);
|
||||||
|
|
||||||
if (TryGetHeadlessCommand(cliArgs, out var headlessKind))
|
if (TryGetHeadlessCommand(cliArgs, out var headlessKind))
|
||||||
{
|
{
|
||||||
var exit = await RunHeadlessAsync(cliArgs, headlessKind);
|
var exit = await RunHeadlessAsync(cliArgs, headlessKind);
|
||||||
@ -198,6 +202,38 @@ static bool TryGetWorkspaceCommand(IReadOnlyList<string> cliArgs, out string com
|
|||||||
return false;
|
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)
|
static int RunWorkspaceCommand(IReadOnlyList<string> cliArgs, string command)
|
||||||
{
|
{
|
||||||
if (!string.Equals(command, "scan", StringComparison.OrdinalIgnoreCase))
|
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]
|
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):
|
Workspace inventory scan (GUI/TUI shared discovery contract):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@ -199,8 +205,11 @@ Primary Python entrypoints:
|
|||||||
- Planned GUI stack for current phase: **Tauri-first**
|
- 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
|
- 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)
|
- 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`
|
- Hybrid GUI bridge is active:
|
||||||
- Second command bridge shipped in scaffold: `sdt run <workflowId> --json` with live stream panel
|
- 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:
|
- GUI will consume:
|
||||||
- `sdt workspace scan --json` inventory payload
|
- `sdt workspace scan --json` inventory payload
|
||||||
- `run/debug --json` summaries
|
- `run/debug --json` summaries
|
||||||
@ -259,6 +268,13 @@ Run Python script smoke checks:
|
|||||||
python -m py_compile scripts/*.py
|
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
|
## Reliability Matrix
|
||||||
|
|
||||||
- CI matrix workflow: [reliability-matrix.yml](/e:/stansshit/csharp/DevTool-master/.github/workflows/reliability-matrix.yml)
|
- 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] 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] 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] `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)
|
## In Progress (next focus)
|
||||||
|
|
||||||
- [ ] Execute full OS matrix verification on Windows/Linux/macOS runners
|
- [ ] 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)
|
- [ ] 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] 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] 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`)
|
- [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
|
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
|
## 4) Manage NuGet cache
|
||||||
|
|
||||||
```powershell
|
```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 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:
|
if preferred:
|
||||||
p = (repo_root / preferred).resolve()
|
p = (repo_root / preferred).resolve()
|
||||||
if (p / "package.json").exists():
|
package_json = p / "package.json"
|
||||||
return p
|
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"
|
package_files = _iter_package_jsons()
|
||||||
if direct.exists():
|
if not package_files:
|
||||||
return repo_root
|
return None
|
||||||
|
|
||||||
tauri_candidates = []
|
# Strong preference: a tauri app root with tauri config and package.json.
|
||||||
for package_json in repo_root.rglob("package.json"):
|
tauri_candidates = [p.parent for p in package_files if _is_tauri_root(p.parent)]
|
||||||
d = package_json.parent
|
|
||||||
if (d / "src-tauri" / "tauri.conf.json").exists():
|
|
||||||
tauri_candidates.append(d)
|
|
||||||
if len(tauri_candidates) == 1:
|
if len(tauri_candidates) == 1:
|
||||||
return tauri_candidates[0]
|
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")]
|
runnable_candidates = [
|
||||||
if len(all_candidates) == 1:
|
p.parent for p in package_files if _has_scripts(p, ("build", "dev", "start", "test", "tauri"))
|
||||||
return all_candidates[0]
|
]
|
||||||
|
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
|
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:
|
Current direction:
|
||||||
|
|
||||||
- `TauriShell/` is the active GUI path for v1.x.
|
- `TauriShell/` is the active GUI path for v1.x.
|
||||||
- TUI and GUI must both consume the same headless SDT contracts:
|
- TUI and GUI consume shared SDT contracts:
|
||||||
- `sdt workspace scan --json`
|
- execution via `sdt run/debug --json`
|
||||||
- `sdt run <workflowId> --json`
|
- read/manage via `sdt bridge --stdio`
|
||||||
- `sdt debug <profileId> --json`
|
- run-event logs via `.sdt/events/*.jsonl`
|
||||||
- `.sdt/events/*.jsonl` run-event stream
|
|
||||||
|
|
||||||
Do not put orchestration logic in GUI code. Keep execution logic in core/headless services.
|
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.
|
This is the first real GUI shell scaffold for SDT.
|
||||||
|
|
||||||
## Implemented Bridge
|
## Implemented Bridge (Hybrid)
|
||||||
|
|
||||||
- Frontend button calls Tauri command: `workspace_scan`
|
- Execution path:
|
||||||
- Rust command runs:
|
- `run_workflow` -> `sdt run <workflowId> --json`
|
||||||
- `sdt workspace scan --json`
|
- `run_debug` -> `sdt debug <profileId> --json`
|
||||||
- fallback: `sdt.exe workspace scan --json` (Windows)
|
- live stream via `run_stream_line` + `run_stream_status`
|
||||||
- fallback: `dotnet run --project DevTool.csproj -- workspace scan --json`
|
- Read/manage path:
|
||||||
- JSON output is rendered in the GUI for inspection.
|
- `bridge_call` -> `sdt bridge --stdio`
|
||||||
- Frontend workflow runner calls Tauri command: `run_workflow`
|
- methods: workspace/favorites/history/events/envProfiles/doctor/setup-plan
|
||||||
- Rust command runs:
|
|
||||||
- `sdt run <workflowId> --json`
|
Fallback execution order:
|
||||||
- fallback: `sdt.exe run <workflowId> --json` (Windows)
|
|
||||||
- fallback: `dotnet run --project DevTool.csproj -- run <workflowId> --json`
|
1. `sdt ...`
|
||||||
- Live run output is streamed into the event panel via Tauri events (`run_stream_line`, `run_stream_status`).
|
2. `sdt.exe ...` (Windows)
|
||||||
|
3. `dotnet run --project DevTool.csproj -- ...`
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@ -29,3 +30,9 @@ npm run tauri dev
|
|||||||
|
|
||||||
- This shell is a thin UI over SDT headless contracts.
|
- This shell is a thin UI over SDT headless contracts.
|
||||||
- Orchestration logic remains in core/headless layers, not GUI code.
|
- 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>
|
<body>
|
||||||
<main class="shell">
|
<main class="shell">
|
||||||
<header>
|
<header>
|
||||||
<h1>SDT GUI Shell</h1>
|
<h1>SDT GUI Shell (Parity Phase 1)</h1>
|
||||||
<p>First bridge command: <code>sdt workspace scan --json</code></p>
|
<p>Hybrid bridge: <code>sdt bridge --stdio</code> + <code>sdt run/debug --json</code></p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<label for="project-root">Project Root (optional)</label>
|
<h2>Execution Context</h2>
|
||||||
<div class="row">
|
<div class="stack">
|
||||||
<input
|
<label for="project-root">Project Root</label>
|
||||||
id="project-root"
|
<input id="project-root" placeholder="E:\stansshit\csharp\DevTool-master" autocomplete="off" />
|
||||||
placeholder="E:\stansshit\csharp\DevTool-master"
|
<label for="env-profile">Env Profile</label>
|
||||||
autocomplete="off"
|
<input id="env-profile" placeholder="dev" autocomplete="off" />
|
||||||
/>
|
<div class="row">
|
||||||
<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>
|
</div>
|
||||||
<p id="scan-status" class="status ok">Ready.</p>
|
|
||||||
<p id="scan-command" class="command"></p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Scan Output</h2>
|
<h2>Workspace + Favorites</h2>
|
||||||
<pre id="scan-output"></pre>
|
<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>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Workflow Run Bridge</h2>
|
<h2>Run Workflow + Debug</h2>
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<label for="workflow-id">Workflow ID</label>
|
<label for="workflow-id">Workflow ID</label>
|
||||||
<input id="workflow-id" placeholder="build" autocomplete="off" />
|
<input id="workflow-id" placeholder="build" autocomplete="off" />
|
||||||
|
<label for="debug-profile-id">Debug Profile ID</label>
|
||||||
<label for="run-project-root">Project Root (optional)</label>
|
<input id="debug-profile-id" placeholder="dotnet-run" autocomplete="off" />
|
||||||
<input
|
<label><input id="verbose-mode" type="checkbox" /> verbose output</label>
|
||||||
id="run-project-root"
|
<div class="row">
|
||||||
placeholder="E:\stansshit\csharp\DevTool-master"
|
<button id="run-workflow-btn" type="button">Run Workflow</button>
|
||||||
autocomplete="off"
|
<button id="run-debug-btn" type="button">Run Debug</button>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="run-status" class="status ok">Ready.</p>
|
<p id="run-status" class="status ok">Ready.</p>
|
||||||
<p id="run-command" class="command"></p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<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>
|
<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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
@ -40,6 +41,23 @@ pub struct RunStreamStatusEvent {
|
|||||||
pub exit_code: Option<i32>,
|
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> {
|
pub fn find_repo_root(start: &Path) -> Option<PathBuf> {
|
||||||
let mut current = Some(start);
|
let mut current = Some(start);
|
||||||
while let Some(dir) = current {
|
while let Some(dir) = current {
|
||||||
@ -171,6 +189,168 @@ pub fn build_workflow_run_attempts(
|
|||||||
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> {
|
pub fn run_attempt_to_completion(invocation: &CommandInvocation) -> Result<CommandExecutionPayload, String> {
|
||||||
let output = Command::new(&invocation.program)
|
let output = Command::new(&invocation.program)
|
||||||
.args(&invocation.args)
|
.args(&invocation.args)
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
mod domain;
|
mod domain;
|
||||||
|
|
||||||
use domain::sdt_bridge::{
|
use domain::sdt_bridge::{
|
||||||
build_workflow_run_attempts, build_workspace_scan_attempts, find_repo_root,
|
build_bridge_stdio_attempts, build_debug_run_attempts, build_workflow_run_attempts,
|
||||||
run_attempt_to_completion, spawn_streaming, stream_child_output, CommandExecutionPayload,
|
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::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@ -16,6 +18,15 @@ struct WorkflowRunRequest {
|
|||||||
session_id: String,
|
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]
|
#[tauri::command]
|
||||||
fn workspace_scan(project_root: Option<String>) -> Result<CommandExecutionPayload, String> {
|
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}"))?;
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.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;
|
sessionId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DebugRunRequest = {
|
||||||
|
profileId: string;
|
||||||
|
projectRoot: string | null;
|
||||||
|
envProfile: string | null;
|
||||||
|
sessionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkflowRunPayload = {
|
export type WorkflowRunPayload = {
|
||||||
command: string;
|
command: string;
|
||||||
workingDirectory: string;
|
workingDirectory: string;
|
||||||
@ -13,6 +20,32 @@ export type WorkflowRunPayload = {
|
|||||||
exitCode: number;
|
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 = {
|
export type RunStreamLineEvent = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
stream: "stdout" | "stderr";
|
stream: "stdout" | "stderr";
|
||||||
@ -25,3 +58,18 @@ export type RunStreamStatusEvent = {
|
|||||||
message: string;
|
message: string;
|
||||||
exitCode: number | null;
|
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 = {
|
export type WorkspaceProject = {
|
||||||
command: string;
|
name: string;
|
||||||
workingDirectory: string;
|
description: string;
|
||||||
stdout: string;
|
path: string;
|
||||||
stderr: string;
|
tags: string[];
|
||||||
exitCode: number;
|
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 { setupParityShell } from "./features/parityShell";
|
||||||
import { setupWorkflowRunFeature } from "./features/workflowRun";
|
|
||||||
|
|
||||||
setupWorkspaceScanFeature();
|
void setupParityShell();
|
||||||
void setupWorkflowRunFeature();
|
|
||||||
|
|||||||
@ -1,11 +1,24 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { WorkspaceScanPayload } from "../domain/workspace";
|
import type {
|
||||||
import type { WorkflowRunPayload, WorkflowRunRequest } from "../domain/workflow";
|
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,
|
projectRoot: string | null,
|
||||||
): Promise<WorkspaceScanPayload> {
|
): Promise<WorkflowRunPayload> {
|
||||||
return invoke<WorkspaceScanPayload>("workspace_scan", { projectRoot });
|
return invoke<WorkflowRunPayload>("workspace_scan", { projectRoot });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runWorkflow(
|
export async function runWorkflow(
|
||||||
@ -13,3 +26,99 @@ export async function runWorkflow(
|
|||||||
): Promise<WorkflowRunPayload> {
|
): Promise<WorkflowRunPayload> {
|
||||||
return invoke<WorkflowRunPayload>("run_workflow", { request });
|
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);
|
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(
|
private static async Task<string> RunPythonAsync(
|
||||||
string workingDir,
|
string workingDir,
|
||||||
string script,
|
string script,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user