Merge branch 'worktree-agent-ac243fcc404f04015' into feat/flow-debugger-script-entry

This commit is contained in:
Joseph Doherty
2026-09-08 01:23:36 -04:00
2 changed files with 38 additions and 15 deletions
@@ -75,13 +75,6 @@ public sealed class FlowDebugService : IFlowDebugService
/// <summary>Upper bound on the template parent chain the script resolver walks (cycle guard).</summary> /// <summary>Upper bound on the template parent chain the script resolver walks (cycle guard).</summary>
private const int MaxTemplateChain = 16; private const int MaxTemplateChain = 16;
/// <summary>
/// Ask-sizing fallback for a call-kind script that declares no timeout of its own — mirrors
/// <c>SiteRuntimeOptions.ScriptExecutionTimeoutSeconds</c>, the site default the run would
/// actually be bounded by (the UI has no site options to read it from).
/// </summary>
private const int DefaultScriptTimeoutSeconds = 30;
private static readonly JsonSerializerOptions ResultJsonOptions = new() private static readonly JsonSerializerOptions ResultJsonOptions = new()
{ {
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
@@ -244,14 +237,15 @@ public sealed class FlowDebugService : IFlowDebugService
{ {
// The server re-resolves the script and never trusts a client-supplied trigger kind // The server re-resolves the script and never trusts a client-supplied trigger kind
// (design 2026-09-08 §2); the kind is read here only to size the Ask wait — a call kind // (design 2026-09-08 §2); the kind is read here only to size the Ask wait — a call kind
// is bounded by the script's own execution timeout, a write kind by the trigger-wait // is bounded by the script's own execution timeout — falling back to the runner's own
// window. An unresolvable script falls back to the write-kind wait, which is the longer // FlowDebugRunner.DefaultScriptTimeoutSeconds so client and server agree on the bound —
// of the two for a default-timeout script. // a write kind by the trigger-wait window. An unresolvable script falls back
// to the write-kind wait, which is the longer of the two for a default-timeout script.
var command = new FlowDebugRunScriptCommand(instanceId, scriptName, parameters, value); var command = new FlowDebugRunScriptCommand(instanceId, scriptName, parameters, value);
var option = (await GetInstanceScriptsAsync(instanceId, cancellationToken)) var option = (await GetInstanceScriptsAsync(instanceId, cancellationToken))
.FirstOrDefault(o => o.CanonicalName.Equals(scriptName, StringComparison.OrdinalIgnoreCase)); .FirstOrDefault(o => o.CanonicalName.Equals(scriptName, StringComparison.OrdinalIgnoreCase));
var timeout = option is { IsWriteKind: false } var timeout = option is { IsWriteKind: false }
? RunAskTimeoutFor(option.ExecutionTimeoutSeconds ?? DefaultScriptTimeoutSeconds) ? RunAskTimeoutFor(option.ExecutionTimeoutSeconds ?? FlowDebugRunner.DefaultScriptTimeoutSeconds)
: ValueChangeAskTimeoutFor(command.WaitSeconds); : ValueChangeAskTimeoutFor(command.WaitSeconds);
return await SendAndBuildAsync(command, timeout, cancellationToken); return await SendAndBuildAsync(command, timeout, cancellationToken);
@@ -411,8 +405,10 @@ public sealed class FlowDebugService : IFlowDebugService
/// Loads an instance's scripts from its flattened configuration (design 2026-09-08 §2) — /// Loads an instance's scripts from its flattened configuration (design 2026-09-08 §2) —
/// canonical names, effective trigger kinds and effective parameter definitions, i.e. exactly /// canonical names, effective trigger kinds and effective parameter definitions, i.e. exactly
/// what the site's Instance Actor keys on — memoised for the life of this scoped service. /// what the site's Instance Actor keys on — memoised for the life of this scoped service.
/// Returns null when the instance does not exist or the flatten failed; a flatten failure is /// Returns null when the instance does not exist (Debug — an id from a stale link) or the
/// a debug-level note, never a page error, because the picker simply has nothing to offer. /// flatten failed (Warning — the same severity <c>DeploymentService</c> and the Configure
/// page give it, since the instance could not be deployed either). Neither is a page error:
/// the picker simply has no scripts to offer.
/// </summary> /// </summary>
private async Task<InstanceScripts?> LoadScriptsAsync(int instanceId, CancellationToken ct) private async Task<InstanceScripts?> LoadScriptsAsync(int instanceId, CancellationToken ct)
{ {
@@ -426,7 +422,9 @@ public sealed class FlowDebugService : IFlowDebugService
var flattened = await _pipeline.FlattenAndValidateAsync(instance.Id, ct, validateScripts: false); var flattened = await _pipeline.FlattenAndValidateAsync(instance.Id, ct, validateScripts: false);
if (flattened.IsFailure) if (flattened.IsFailure)
{ {
_logger.LogDebug("Flow Debugger could not flatten instance {InstanceId}: {Error}", instanceId, flattened.Error); _logger.LogWarning(
"Flow Debugger could not flatten instance {InstanceId} ({UniqueName}): {Error}",
instanceId, instance.UniqueName, flattened.Error);
return null; return null;
} }
@@ -371,6 +371,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Equal(2, _received.Count); Assert.Equal(2, _received.Count);
var vcCmd = Assert.IsType<FlowDebugRunScriptCommand>(_received[0].Command); var vcCmd = Assert.IsType<FlowDebugRunScriptCommand>(_received[0].Command);
Assert.Equal((7, "OnSpeed", "42"), (vcCmd.InstanceId, vcCmd.ScriptName, vcCmd.Value)); Assert.Equal((7, "OnSpeed", "42"), (vcCmd.InstanceId, vcCmd.ScriptName, vcCmd.Value));
// A write kind fires through the attribute, so it carries no parameters.
Assert.Null(vcCmd.Parameters);
var rpCmd = Assert.IsType<FlowDebugReplayCommand>(_received[1].Command); var rpCmd = Assert.IsType<FlowDebugReplayCommand>(_received[1].Command);
Assert.Equal(exec, rpCmd.ExecutionId); Assert.Equal(exec, rpCmd.ExecutionId);
} }
@@ -607,13 +609,36 @@ public sealed class FlowDebugServiceTests : IDisposable
WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call", ExecutionTimeoutSeconds = 40 }); WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call", ExecutionTimeoutSeconds = 40 });
var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow);
var svc = CreateService(reply: new ManagementSuccess("c", JsonSerializer.Serialize(raw, ServerJson))); var svc = CreateService(reply: new ManagementSuccess("c", JsonSerializer.Serialize(raw, ServerJson)));
using var doc = JsonDocument.Parse("""{"n":3}""");
var outcome = await svc.RunScriptAsync(9, "Manual", null, null); var outcome = await svc.RunScriptAsync(9, "Manual", doc.RootElement.Clone(), null);
Assert.True(outcome.Success, outcome.Error); Assert.True(outcome.Success, outcome.Error);
var env = Assert.Single(_received); var env = Assert.Single(_received);
var cmd = Assert.IsType<FlowDebugRunScriptCommand>(env.Command); var cmd = Assert.IsType<FlowDebugRunScriptCommand>(env.Command);
Assert.Equal((9, "Manual"), (cmd.InstanceId, cmd.ScriptName)); Assert.Equal((9, "Manual"), (cmd.InstanceId, cmd.ScriptName));
// A call kind carries its parameters verbatim and no value.
Assert.Equal(3, cmd.Parameters!.Value.GetProperty("n").GetInt32());
Assert.Null(cmd.Value);
}
/// <summary>
/// The picker, the prediction and the run of one instance share ONE flatten for the life of
/// the scoped service (design 2026-09-08 §5) — a regression here means every keystroke on the
/// page re-walks the template chain.
/// </summary>
[Fact]
public async Task GetInstanceScripts_ThenPredict_ThenRun_FlattenOnce()
{
WireFlattened(9, "Sim01", new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call" });
var raw = new FlowDebugRunResult(Guid.NewGuid(), true, null, null, [], false, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow);
var svc = CreateService(reply: new ManagementSuccess("c", JsonSerializer.Serialize(raw, ServerJson)));
await svc.GetInstanceScriptsAsync(9);
await svc.PredictScriptAsync(9, "Manual");
await svc.RunScriptAsync(9, "Manual", null, null);
await _pipeline.Received(1).FlattenAndValidateAsync(9, Arg.Any<CancellationToken>(), false, null);
} }
private sealed class StubAuth : AuthenticationStateProvider private sealed class StubAuth : AuthenticationStateProvider