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

This commit is contained in:
Joseph Doherty
2026-09-08 01:41:31 -04:00
2 changed files with 79 additions and 29 deletions
@@ -90,11 +90,15 @@ public sealed class FlowDebugService : IFlowDebugService
private readonly ILogger<FlowDebugService> _logger;
/// <summary>
/// Flattened script lists by instance id. The service is scoped (one per Blazor circuit
/// interaction), so this costs at most one flatten per instance the operator selects,
/// and the picker, the prediction and the run all read the same snapshot. The raw
/// <see cref="ResolvedScript"/>s ride along because prediction needs the code bodies,
/// which the picker's projection deliberately does not carry.
/// Flattened script lists by instance id. A scoped service in Blazor Server lives for the
/// whole CIRCUIT, so this is emphatically not a per-interaction memo: it is refreshed by
/// every <see cref="GetInstanceScriptsAsync"/> call (the page makes one per instance
/// selection), and only the prediction and the run read it back. That keeps one flatten per
/// picker selection while making a re-selection pick up a template edit or a redeploy — a
/// cache held for the session would otherwise serve a stale parameter schema (unexplained
/// <c>VALIDATION_FAILED</c>) and a stale Ask bound against a runner that re-flattens on every
/// run. The raw <see cref="ResolvedScript"/>s ride along because prediction needs the code
/// bodies, which the picker's projection deliberately does not carry.
/// </summary>
private readonly Dictionary<int, InstanceScripts> _scriptCache = new();
@@ -188,12 +192,12 @@ public sealed class FlowDebugService : IFlowDebugService
/// <inheritdoc/>
public async Task<IReadOnlyList<FlowScriptOption>> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default)
=> (await LoadScriptsAsync(instanceId, cancellationToken))?.Options ?? [];
=> (await FlattenScriptsAsync(instanceId, cancellationToken))?.Options ?? [];
/// <inheritdoc/>
public async Task<PredictedFlow> PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default)
{
var loaded = await LoadScriptsAsync(instanceId, cancellationToken);
var loaded = await CachedScriptsAsync(instanceId, cancellationToken);
var script = loaded?.Scripts.FirstOrDefault(s => s.CanonicalName.Equals(scriptName, StringComparison.OrdinalIgnoreCase));
if (loaded == null || script == null)
return PredictedFlow.Empty;
@@ -237,16 +241,23 @@ public sealed class FlowDebugService : IFlowDebugService
{
// 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
// is bounded by the script's own execution timeout — falling back to the runner's own
// FlowDebugRunner.DefaultScriptTimeoutSeconds so client and server agree on the bound —
// 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.
// is bounded by the script's own execution timeout (falling back to the runner's own
// FlowDebugRunner.DefaultScriptTimeoutSeconds so client and server agree on the bound),
// a write kind by the trigger-wait window. When the script cannot be resolved at all the
// server may still take EITHER branch, so the fallback is the longer of the two — a
// client Ask shorter than the server's bound would report a phantom timeout on a run
// that is still going.
var command = new FlowDebugRunScriptCommand(instanceId, scriptName, parameters, value);
var option = (await GetInstanceScriptsAsync(instanceId, cancellationToken))
var option = (await CachedScriptsAsync(instanceId, cancellationToken))?.Options
.FirstOrDefault(o => o.CanonicalName.Equals(scriptName, StringComparison.OrdinalIgnoreCase));
var timeout = option is { IsWriteKind: false }
? RunAskTimeoutFor(option.ExecutionTimeoutSeconds ?? FlowDebugRunner.DefaultScriptTimeoutSeconds)
: ValueChangeAskTimeoutFor(command.WaitSeconds);
var writeKindWait = ValueChangeAskTimeoutFor(command.WaitSeconds);
var defaultCallWait = RunAskTimeoutFor(FlowDebugRunner.DefaultScriptTimeoutSeconds);
var timeout = option switch
{
{ IsWriteKind: false } => RunAskTimeoutFor(option.ExecutionTimeoutSeconds ?? FlowDebugRunner.DefaultScriptTimeoutSeconds),
not null => writeKindWait,
null => writeKindWait > defaultCallWait ? writeKindWait : defaultCallWait,
};
return await SendAndBuildAsync(command, timeout, cancellationToken);
}
@@ -402,22 +413,31 @@ public sealed class FlowDebugService : IFlowDebugService
}
/// <summary>
/// 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
/// what the site's Instance Actor keys on — memoised for the life of this scoped service.
/// Returns null when the instance does not exist (Debug — an id from a stale link) or the
/// 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.
/// The instance's scripts as the current picker selection knows them: the cached entry a
/// <see cref="GetInstanceScriptsAsync"/> call just refreshed, or a fresh flatten when the
/// prediction / run is reached without one (a deep link straight into a run).
/// </summary>
private async Task<InstanceScripts?> LoadScriptsAsync(int instanceId, CancellationToken ct)
{
if (_scriptCache.TryGetValue(instanceId, out var cached))
return cached;
private async Task<InstanceScripts?> CachedScriptsAsync(int instanceId, CancellationToken ct)
=> _scriptCache.TryGetValue(instanceId, out var cached) ? cached : await FlattenScriptsAsync(instanceId, ct);
/// <summary>
/// Flattens an instance's scripts (design 2026-09-08 §2) — canonical names, effective trigger
/// kinds and effective parameter definitions, i.e. exactly what the site's Instance Actor keys
/// on — and refreshes the cache entry, so a template edit or a redeploy is picked up on the
/// next picker selection. Returns null when the instance does not exist (Debug — an id from a
/// stale link) or the flatten failed (Warning — the same severity <c>DeploymentService</c> and
/// the Configure page give it, since the instance could not be deployed either); either way
/// the stale entry is dropped rather than served on. Neither is a page error: the picker
/// simply has no scripts to offer.
/// </summary>
private async Task<InstanceScripts?> FlattenScriptsAsync(int instanceId, CancellationToken ct)
{
var instance = await _templates.GetInstanceByIdAsync(instanceId, ct);
if (instance == null)
{
_scriptCache.Remove(instanceId);
return null;
}
var flattened = await _pipeline.FlattenAndValidateAsync(instance.Id, ct, validateScripts: false);
if (flattened.IsFailure)
@@ -425,6 +445,7 @@ public sealed class FlowDebugService : IFlowDebugService
_logger.LogWarning(
"Flow Debugger could not flatten instance {InstanceId} ({UniqueName}): {Error}",
instanceId, instance.UniqueName, flattened.Error);
_scriptCache.Remove(instanceId);
return null;
}
@@ -623,9 +623,9 @@ public sealed class FlowDebugServiceTests : IDisposable
}
/// <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.
/// One picker selection costs ONE flatten: the prediction and the run that follow it read the
/// entry <see cref="IFlowDebugService.GetInstanceScriptsAsync"/> just refreshed (design
/// 2026-09-08 §5). A regression here means every keystroke re-walks the template chain.
/// </summary>
[Fact]
public async Task GetInstanceScripts_ThenPredict_ThenRun_FlattenOnce()
@@ -641,6 +641,35 @@ public sealed class FlowDebugServiceTests : IDisposable
await _pipeline.Received(1).FlattenAndValidateAsync(9, Arg.Any<CancellationToken>(), false, null);
}
/// <summary>
/// A scoped service lives for the whole Blazor circuit, so re-selecting the instance must
/// re-flatten — otherwise a template edit or a redeploy mid-session leaves the picker, the
/// parameter form and the Ask sizing stale for the rest of the session.
/// </summary>
[Fact]
public async Task GetInstanceScripts_CalledAgain_ReflattensAndRefreshes()
{
_templates.GetInstanceByIdAsync(9, Arg.Any<CancellationToken>())
.Returns(new Instance("Sim01") { Id = 9, TemplateId = 10, SiteId = 1 });
static Result<FlatteningPipelineResult> Flattened(params ResolvedScript[] scripts)
=> Result<FlatteningPipelineResult>.Success(new FlatteningPipelineResult(
new FlattenedConfiguration { InstanceUniqueName = "Sim01", TemplateId = 10, SiteId = 1, Scripts = scripts },
"h", new ValidationResult()));
_pipeline.FlattenAndValidateAsync(9, Arg.Any<CancellationToken>(), false, null).Returns(
Flattened(new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call" }),
Flattened(
new ResolvedScript { CanonicalName = "Manual", Code = "x", TriggerType = "Call" },
new ResolvedScript { CanonicalName = "Added", Code = "x", TriggerType = "Call" }));
var svc = CreateService(reply: null);
var first = await svc.GetInstanceScriptsAsync(9);
var second = await svc.GetInstanceScriptsAsync(9);
Assert.Equal(["Manual"], first.Select(o => o.CanonicalName).ToList());
Assert.Equal(["Added", "Manual"], second.Select(o => o.CanonicalName).ToList());
await _pipeline.Received(2).FlattenAndValidateAsync(9, Arg.Any<CancellationToken>(), false, null);
}
private sealed class StubAuth : AuthenticationStateProvider
{
private readonly ClaimsPrincipal _user;