Merge branch 'worktree-agent-ac243fcc404f04015' into feat/flow-debugger-script-entry
This commit is contained in:
@@ -90,11 +90,15 @@ public sealed class FlowDebugService : IFlowDebugService
|
|||||||
private readonly ILogger<FlowDebugService> _logger;
|
private readonly ILogger<FlowDebugService> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Flattened script lists by instance id. The service is scoped (one per Blazor circuit
|
/// Flattened script lists by instance id. A scoped service in Blazor Server lives for the
|
||||||
/// interaction), so this costs at most one flatten per instance the operator selects,
|
/// whole CIRCUIT, so this is emphatically not a per-interaction memo: it is refreshed by
|
||||||
/// and the picker, the prediction and the run all read the same snapshot. The raw
|
/// every <see cref="GetInstanceScriptsAsync"/> call (the page makes one per instance
|
||||||
/// <see cref="ResolvedScript"/>s ride along because prediction needs the code bodies,
|
/// selection), and only the prediction and the run read it back. That keeps one flatten per
|
||||||
/// which the picker's projection deliberately does not carry.
|
/// 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>
|
/// </summary>
|
||||||
private readonly Dictionary<int, InstanceScripts> _scriptCache = new();
|
private readonly Dictionary<int, InstanceScripts> _scriptCache = new();
|
||||||
|
|
||||||
@@ -188,12 +192,12 @@ public sealed class FlowDebugService : IFlowDebugService
|
|||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<IReadOnlyList<FlowScriptOption>> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<FlowScriptOption>> GetInstanceScriptsAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||||
=> (await LoadScriptsAsync(instanceId, cancellationToken))?.Options ?? [];
|
=> (await FlattenScriptsAsync(instanceId, cancellationToken))?.Options ?? [];
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<PredictedFlow> PredictScriptAsync(int instanceId, string scriptName, CancellationToken cancellationToken = default)
|
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));
|
var script = loaded?.Scripts.FirstOrDefault(s => s.CanonicalName.Equals(scriptName, StringComparison.OrdinalIgnoreCase));
|
||||||
if (loaded == null || script == null)
|
if (loaded == null || script == null)
|
||||||
return PredictedFlow.Empty;
|
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
|
// 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 — falling back to the runner's own
|
// 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 —
|
// FlowDebugRunner.DefaultScriptTimeoutSeconds so client and server agree on the bound),
|
||||||
// a write kind by the trigger-wait window. An unresolvable script falls back
|
// a write kind by the trigger-wait window. When the script cannot be resolved at all the
|
||||||
// to the write-kind wait, which is the longer of the two for a default-timeout script.
|
// 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 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));
|
.FirstOrDefault(o => o.CanonicalName.Equals(scriptName, StringComparison.OrdinalIgnoreCase));
|
||||||
var timeout = option is { IsWriteKind: false }
|
var writeKindWait = ValueChangeAskTimeoutFor(command.WaitSeconds);
|
||||||
? RunAskTimeoutFor(option.ExecutionTimeoutSeconds ?? FlowDebugRunner.DefaultScriptTimeoutSeconds)
|
var defaultCallWait = RunAskTimeoutFor(FlowDebugRunner.DefaultScriptTimeoutSeconds);
|
||||||
: ValueChangeAskTimeoutFor(command.WaitSeconds);
|
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);
|
return await SendAndBuildAsync(command, timeout, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -402,22 +413,31 @@ public sealed class FlowDebugService : IFlowDebugService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads an instance's scripts from its flattened configuration (design 2026-09-08 §2) —
|
/// The instance's scripts as the current picker selection knows them: the cached entry a
|
||||||
/// canonical names, effective trigger kinds and effective parameter definitions, i.e. exactly
|
/// <see cref="GetInstanceScriptsAsync"/> call just refreshed, or a fresh flatten when the
|
||||||
/// what the site's Instance Actor keys on — memoised for the life of this scoped service.
|
/// prediction / run is reached without one (a deep link straight into a run).
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<InstanceScripts?> LoadScriptsAsync(int instanceId, CancellationToken ct)
|
private async Task<InstanceScripts?> CachedScriptsAsync(int instanceId, CancellationToken ct)
|
||||||
{
|
=> _scriptCache.TryGetValue(instanceId, out var cached) ? cached : await FlattenScriptsAsync(instanceId, ct);
|
||||||
if (_scriptCache.TryGetValue(instanceId, out var cached))
|
|
||||||
return cached;
|
|
||||||
|
|
||||||
|
/// <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);
|
var instance = await _templates.GetInstanceByIdAsync(instanceId, ct);
|
||||||
if (instance == null)
|
if (instance == null)
|
||||||
|
{
|
||||||
|
_scriptCache.Remove(instanceId);
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -425,6 +445,7 @@ public sealed class FlowDebugService : IFlowDebugService
|
|||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"Flow Debugger could not flatten instance {InstanceId} ({UniqueName}): {Error}",
|
"Flow Debugger could not flatten instance {InstanceId} ({UniqueName}): {Error}",
|
||||||
instanceId, instance.UniqueName, flattened.Error);
|
instanceId, instance.UniqueName, flattened.Error);
|
||||||
|
_scriptCache.Remove(instanceId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -623,9 +623,9 @@ public sealed class FlowDebugServiceTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The picker, the prediction and the run of one instance share ONE flatten for the life of
|
/// One picker selection costs ONE flatten: the prediction and the run that follow it read the
|
||||||
/// the scoped service (design 2026-09-08 §5) — a regression here means every keystroke on the
|
/// entry <see cref="IFlowDebugService.GetInstanceScriptsAsync"/> just refreshed (design
|
||||||
/// page re-walks the template chain.
|
/// 2026-09-08 §5). A regression here means every keystroke re-walks the template chain.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetInstanceScripts_ThenPredict_ThenRun_FlattenOnce()
|
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);
|
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 sealed class StubAuth : AuthenticationStateProvider
|
||||||
{
|
{
|
||||||
private readonly ClaimsPrincipal _user;
|
private readonly ClaimsPrincipal _user;
|
||||||
|
|||||||
Reference in New Issue
Block a user