fix(inbound-api): surface compile warning on non-compiling method save

CompileAndRegister only swaps the cached handler on a successful compile, so a
management (or DB) save of a non-compiling inbound script silently kept the
previously-registered handler serving — stale results, still HTTP 200 — while
UpdateApiMethod reported success. A broken save thus looked like a no-op, with
the real Roslyn error only in the central log.

Surface the diagnostics instead: a new CompileAndRegister(method, out errors)
overload reports the compile/trust-model errors (the bool overload is kept for
existing callers), and HandleCreate/UpdateApiMethod return them as a top-level
compileWarning on the management result (null when the script compiled). The
save still persists and the previously registered version keeps serving — the
warning just stops a non-compiling save from looking like a success.

- InboundScriptExecutor: Compile returns its diagnostics alongside the handler.
- ManagementActor: TryCompileAndDescribe + flat ApiMethodResult projection so
  compileWarning rides at the top level without polluting the ApiMethod POCO.
- Component-InboundAPI.md: document the non-fatal-but-surfaced contract.
- Tests: executor diagnostics + non-compiling-update-keeps-previous-handler,
  plus an end-to-end ManagementActor test asserting compileWarning is returned.
This commit is contained in:
Joseph Doherty
2026-06-30 10:44:18 -04:00
parent 73ce2e4d0f
commit 67005ca4c0
5 changed files with 160 additions and 14 deletions
@@ -2596,9 +2596,12 @@ public class ManagementActor : ReceiveActor
};
await repo.AddApiMethodAsync(method);
await repo.SaveChangesAsync();
sp.GetService<InboundAPI.InboundScriptExecutor>()?.CompileAndRegister(method);
// The script is persisted regardless; surface a non-fatal warning when it does
// not compile so the caller is not misled into thinking a broken method is live
// (a never-compiled method returns "Script compilation failed" on first call).
var compileWarning = TryCompileAndDescribe(sp, method, isUpdate: false);
await AuditAsync(sp, user, "Create", "ApiMethod", method.Id.ToString(), method.Name, method);
return method;
return ApiMethodResult(method, compileWarning);
}
private static async Task<object?> HandleUpdateApiMethod(IServiceProvider sp, UpdateApiMethodCommand cmd, string user)
@@ -2612,9 +2615,12 @@ public class ManagementActor : ReceiveActor
method.ReturnDefinition = cmd.ReturnDefinition;
await repo.UpdateApiMethodAsync(method);
await repo.SaveChangesAsync();
sp.GetService<InboundAPI.InboundScriptExecutor>()?.CompileAndRegister(method);
// On a compile failure the previously registered handler keeps serving — the
// saved-but-broken script does NOT go live. Surface that as a warning rather
// than a silent success (the change still persists either way).
var compileWarning = TryCompileAndDescribe(sp, method, isUpdate: true);
await AuditAsync(sp, user, "Update", "ApiMethod", method.Id.ToString(), method.Name, method);
return method;
return ApiMethodResult(method, compileWarning);
}
private static async Task<object?> HandleDeleteApiMethod(IServiceProvider sp, DeleteApiMethodCommand cmd, string user)
@@ -2629,6 +2635,46 @@ public class ManagementActor : ReceiveActor
return true;
}
/// <summary>
/// Recompiles and re-registers an inbound API method's script after a Create/Update,
/// returning a non-null human-readable warning when the script did NOT compile. The
/// save is always persisted; on compile failure the previously registered handler (if
/// any) keeps serving requests — the broken script never replaces a working one (see
/// <c>InboundScriptExecutor.CompileAndRegister</c>). Returns <c>null</c> when the
/// script compiled, or when no executor is registered (e.g. a non-inbound host or a
/// test double).
/// </summary>
private static string? TryCompileAndDescribe(IServiceProvider sp, ApiMethod method, bool isUpdate)
{
var executor = sp.GetService<InboundAPI.InboundScriptExecutor>();
if (executor is null)
return null;
if (executor.CompileAndRegister(method, out var errors))
return null;
var detail = string.Join("; ", errors);
return isUpdate
? $"Script saved but failed to compile; the previously registered version (if any) remains active and the new script is NOT live. Compilation errors: {detail}"
: $"Script saved but failed to compile; the method will return a compilation error until a compiling version is saved. Compilation errors: {detail}";
}
/// <summary>
/// Projects an <see cref="ApiMethod"/> to the Create/Update response shape, adding a
/// top-level <c>compileWarning</c> (null when the script compiled). Kept as a flat
/// projection so the method fields stay top-level for the CLI/Central UI and the
/// persistence-ignorant POCO entity is not polluted with a transient management field.
/// </summary>
private static object ApiMethodResult(ApiMethod method, string? compileWarning) => new
{
method.Id,
method.Name,
method.Script,
method.ParameterDefinitions,
method.ReturnDefinition,
method.TimeoutSeconds,
compileWarning,
};
// ========================================================================
// Additional Security handlers (API key update, scope rules)
// ========================================================================