feat(inbound-api): surface method compile errors to operators — api-method validate/recompile + compile state on get (ScadaBridge#75)

The data plane answers a broken inbound method with the generic "Script
compilation failed for this method"; the Roslyn diagnostic reached only the
central log, and the known-bad cache could not be observed or reset per method.

- InboundScriptExecutor keeps the diagnostics with each known-bad record and
  exposes GetCompileState(name, currentScript): IsCompiled / IsKnownBad /
  LastCompileError, judged against the current script text (a record for older
  text is reported as not compiled yet).
- Management: ValidateApiMethodCommand (compile the stored script, return
  ApiMethodCompileResult, register nothing, cache untouched) and
  RecompileApiMethodCommand (CompileAndRegister without re-sending the entity,
  drops the node's ApiMethodCache row, audited "Recompile"). Both Designer, like
  UpdateApiMethod; dispatched through the shared ApiMethodCompileOperations.
  GetApiMethod keeps the entity fields top-level and adds isCompiled,
  isKnownBad, lastCompileError and compileStateNode. Compile state is per
  central node and every result names the node.
- CLI: api-method validate --id, api-method recompile --id.
- Central UI: the API-method edit page shows the saved script's compile state
  and last error, with Validate and Recompile actions (same operations, same
  Designer gate, fresh DI scope).
- RequiredRoleMatrixTests entries; ManagementSurfaceParityTests covers the new
  CLI leaves; ApiMethodCompileDiagnosticsTests + an executor CompileState test.
- Docs: CLI README, Component-CLI/ManagementService/InboundAPI, components/*.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 15:28:41 -05:00
parent 65ffa1dad4
commit 3dd492a77f
18 changed files with 542 additions and 15 deletions
@@ -9,6 +9,9 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi
@using ZB.MOM.WW.ScadaBridge.ManagementService
@using Microsoft.Extensions.DependencyInjection
@using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IInboundApiRepository InboundApiRepository
@@ -123,6 +126,65 @@
</div>
</fieldset>
@if (_existing != null)
{
@* ScadaBridge#75: the data plane answers a broken method with a generic
"Script compilation failed for this method"; operators read the real
diagnostics here (same operations as CLI api-method get/validate/recompile). *@
<fieldset class="mb-4" data-test="api-method-compile">
<legend class="h6">Compilation (saved script)</legend>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
@if (_compileStatus is null)
{
<span class="badge bg-secondary" data-test="api-method-compile-status">Unavailable on this node</span>
}
else if (_compileStatus.IsKnownBad)
{
<span class="badge bg-danger" data-test="api-method-compile-status">Compile failed</span>
}
else if (_compileStatus.IsCompiled)
{
<span class="badge bg-success" data-test="api-method-compile-status">Compiled</span>
}
else
{
<span class="badge bg-secondary" data-test="api-method-compile-status">Not compiled yet</span>
}
@if (_compileStatus?.Node is { } node)
{
<span class="text-muted small">on node @node</span>
}
<button class="btn btn-outline-secondary btn-sm" data-test="api-method-validate"
@onclick="ValidateSavedScriptAsync" disabled="@_compiling">Validate</button>
<button class="btn btn-outline-secondary btn-sm" data-test="api-method-recompile"
@onclick="RecompileSavedScriptAsync" disabled="@_compiling">Recompile</button>
</div>
@if (_compileStatus?.LastCompileError is { } lastError)
{
<pre class="border border-danger-subtle rounded p-2 small mb-2 font-monospace text-danger" style="white-space: pre-wrap;">@lastError</pre>
}
@if (_compileResult != null)
{
<div class="small @(_compileResult.Success ? "text-success" : "text-danger")" data-test="api-method-compile-result">
@_compileAction: @(_compileResult.Success ? "the saved script compiles." : "the saved script does not compile.")
</div>
@if (_compileResult.Errors.Count > 0)
{
<ul class="small text-danger mt-1 mb-0">
@foreach (var error in _compileResult.Errors)
{
<li>@error</li>
}
</ul>
}
}
<div class="form-text">
Checks the script as saved, not unsaved edits. Recompile re-registers it on this
central node, clearing a stale compile failure.
</div>
</fieldset>
}
@if (_formError != null)
{
<div class="text-danger small mb-2">@_formError</div>
@@ -245,6 +307,10 @@
private HashSet<string> _initialKeyIds = new(StringComparer.Ordinal);
private bool _showTestRun;
private ApiMethodCompileStatus? _compileStatus;
private ApiMethodCompileResult? _compileResult;
private string? _compileAction;
private bool _compiling;
private bool _running;
private Dictionary<string, object?> _paramValues = new();
private ScriptAnalysis.SandboxRunResult? _runResult;
@@ -277,6 +343,7 @@
var keysForMethod = await ApiKeyAdmin.GetKeysForMethodAsync(_existing.Name);
_initialKeyIds = new HashSet<string>(keysForMethod, StringComparer.Ordinal);
_selectedKeyIds = new HashSet<string>(_initialKeyIds, StringComparer.Ordinal);
_compileStatus = ApiMethodCompileOperations.GetStatus(MutationServices, _existing);
}
}
catch (Exception ex) { _formError = ex.Message; }
@@ -284,6 +351,35 @@
_loading = false;
}
private Task ValidateSavedScriptAsync() =>
RunCompileActionAsync("Validate", (sp, _) => ApiMethodCompileOperations.ValidateAsync(sp, _existing!.Id));
private Task RecompileSavedScriptAsync() =>
RunCompileActionAsync("Recompile", (sp, user) => ApiMethodCompileOperations.RecompileAsync(sp, _existing!.Id, user));
/// <summary>
/// Runs a compile action under the same Designer gate as the management commands, in a
/// fresh DI scope (the recompile writes an audit row), then refreshes the status badge.
/// </summary>
private async Task RunCompileActionAsync(
string action, Func<IServiceProvider, string, Task<ApiMethodCompileResult>> operation)
{
if (_existing is null) return;
_formError = null;
_compiling = true;
try
{
var user = await MutationAuth.RequireRoleAsync(Roles.Designer);
await using var scope = MutationServices.GetRequiredService<IServiceScopeFactory>().CreateAsyncScope();
_compileResult = await operation(scope.ServiceProvider, user);
_compileAction = action;
var saved = await scope.ServiceProvider.GetRequiredService<IInboundApiRepository>().GetApiMethodByIdAsync(_existing.Id);
_compileStatus = saved is null ? null : ApiMethodCompileOperations.GetStatus(scope.ServiceProvider, saved);
}
catch (Exception ex) { _formError = ex.Message; }
finally { _compiling = false; }
}
private void ToggleKey(string keyId, bool isChecked)
{
if (isChecked) _selectedKeyIds.Add(keyId);