diff --git a/docs/reference/actions.md b/docs/reference/actions.md index d3ace88..3215ddd 100644 --- a/docs/reference/actions.md +++ b/docs/reference/actions.md @@ -1,4 +1,4 @@ -# Action discovery +# Action discovery and workflow execution Use `wrighty actions` to inspect what you can do with an item before choosing an operation: @@ -15,10 +15,39 @@ reason codes. An optional action name selects one descriptor; an unknown name re Wrighty's normal nonzero exit status and stderr JSON contract. Do not combine a selected name with `--all`. -Discovery is read-only. All descriptors currently report `execution: "manual-only"`, and `--exec` -returns `ACTION_EXECUTION_UNSUPPORTED`. Review the displayed guidance and use the existing focused -CLI command or web control when you have authorized the operation. Listing an action never claims -an item, starts a vendor session, grants permission, or overrides a pending retry. +Discovery is read-only. Queue, Send back, and Resume report `execution: "supported"`; other +catalogue entries remain `manual-only`. Listing an action never claims an item, starts a vendor +session, grants permission, or overrides a pending retry. + +## Execute one workflow action + +```shell +wrighty actions local:42 queue --json +wrighty actions local:42 queue --exec --yes --expected-version --json +wrighty actions local:42 send-back --exec --yes --json +wrighty actions local:42 resume --exec --yes --json +``` + +These three executors support Local Markdown and share the web Board's policy and backend +operation. `--exec` requires one action name and cannot use `--all`. An interactive invocation +shows the consequence and prompts; redirected input and JSON require `--yes`. This authorizes +only the named operation. Neither discovery nor `--yes` grants takeover or starts a worker. + +Supply the discovery's `stateVersion` with `--expected-version` when executing a reviewed +snapshot. Wrighty always reads current state again, then validates and mutates under the local +store lock. A changed item, claim, session, or configuration refuses the old version with +`ACTION_STATE_CHANGED` (or the current action's more specific refusal). The fingerprint is not a +reservation or a credential. Without it, execution uses a fresh observation from this invocation. +Manual-only actions still return `ACTION_EXECUTION_UNSUPPORTED`; commands, URLs, and item text +are never interpreted as executors or shell input. + +Execution JSON has `schemaVersion: 1` and a `result` with `itemId`, `action`, `outcome: "applied"`, +`observedAt`, `stateVersion`, `before`, `after`, and `startsWorker: false`. Each state contains the +workflow status, operational status, execution authorization, and dispatch marker. `workers` +contains a fresh pickup assessment after mutation. If that follow-up fails, `refreshError` is +`WORKER_REFRESH_UNAVAILABLE` and the result still says applied; inspect before attempting another +mutation. A worker may claim the item immediately afterward, so even a successful result remains +an observation rather than a reservation. ## Action vocabulary @@ -37,7 +66,7 @@ an item, starts a vendor session, grants permission, or overrides a pending retr | `inspect-recovery` | Read the item and operational status for current recovery details. | Names are stable selectors; titles and descriptions are presentation. Queue, Send back, and Resume -currently describe the corresponding Board controls; they do not have generic CLI executors. +share the corresponding Board controls' eligibility and execution path. With worker-queue authorization enabled, Queue authorizes automatic processing and Send back revokes that authorization. When it is disabled, execution policy remains independent. Resume queues the recorded session and does not start a worker. These actions are not interchangeable status moves. @@ -50,7 +79,7 @@ process immediately. Recommendations never authorize execution. `--json` returns `schemaVersion: 1` and `result` containing: -- `itemId`, `stateObservedAt`, and nullable `recommendedAction`; +- `itemId`, `stateObservedAt`, `stateVersion`, and nullable `recommendedAction`; - `actions[]` with `name`, `title`, `description`, and `recommended`; - `availability`, `unavailableCode`, and `unavailableReason`; - `kind`, `execution`, `confirmation`, `requiresTty`, and `startsProcess`; and diff --git a/docs/reference/agent-skills.md b/docs/reference/agent-skills.md index b39e98f..e57f02b 100644 --- a/docs/reference/agent-skills.md +++ b/docs/reference/agent-skills.md @@ -185,3 +185,9 @@ shared action discovery, and scoped worker/pickup assessment. `list --json` keep and adds `listing` metadata: status order, archive/filter/limit scope, returned count, and whether the result may be truncated. See [worker discovery](workers.md). Update an installed skill through the existing `wrighty skill update` command to receive these workflows. + +The bundled skill also executes individual Local Markdown Queue, Send back, and Resume requests +through `actions --exec --yes --expected-version --json`, after the +requested effect is authorized. CLI and web share eligibility and locked revalidation. Execution +returns the resulting state and refreshed worker pickup evidence; it does not launch a worker. +See [action execution](actions.md). diff --git a/skills/wrighty/SKILL.md b/skills/wrighty/SKILL.md index 8f5f68f..ba47e2c 100644 --- a/skills/wrighty/SKILL.md +++ b/skills/wrighty/SKILL.md @@ -1,17 +1,17 @@ --- name: wrighty -description: Safely operate Wrighty through the `wrighty` CLI. Use only when the user explicitly asks to use Wrighty, the Wrighty CLI, or a Wrighty work-item ID, including board overview, worker discovery, pickup assessment, and requests to list, inspect, create, pick, claim, edit, move, finish, archive, or release tracked work. Do not trigger for generic tasks, GitHub issues, planning, backlogs, or project management that do not explicitly identify Wrighty. +description: Safely operate Wrighty through the `wrighty` CLI. Use only when the user explicitly asks to use Wrighty, the Wrighty CLI, or a Wrighty work-item ID, including board overview, worker discovery, pickup assessment, Queue/Send back/Resume actions, and requests to list, inspect, create, pick, claim, edit, move, finish, archive, or release tracked work. Do not trigger for generic tasks, GitHub issues, planning, backlogs, or project management that do not explicitly identify Wrighty. --- # Wrighty - + Operate Wrighty state only through the `wrighty` command. Never mutate tracked state by editing local Markdown, invoking `gh`, calling GitHub APIs/MCP, writing claim comments, or changing Project fields directly. -For board overview, triage, available actions, worker discovery, and item pickup prospects, read +For board overview, triage, individual Queue/Send back/Resume actions, worker discovery, and pickup prospects, read [references/board-and-workers.md](references/board-and-workers.md). Use `list --json` for workflow columns, `actions --json` for the shared action catalogue, and `workers [--item ] --json` for scoped worker evidence. Discovery does not authorize mutations or worker launches. diff --git a/skills/wrighty/references/board-and-workers.md b/skills/wrighty/references/board-and-workers.md index 160e5ab..4c6e89c 100644 --- a/skills/wrighty/references/board-and-workers.md +++ b/skills/wrighty/references/board-and-workers.md @@ -34,10 +34,36 @@ Retry-scheduled and handoff-queued work is deferred; do not classify it as await Use `wrighty actions --json` for available actions; add `--all` when the user asks why an alternative is unavailable. Use action names, reasons, recommendation, and execution metadata -from the response. A recommendation is advice, not execution authority. The current catalogue is -manual-only: `actions --exec` refuses execution. Queue/Send back/Resume descriptors currently -refer to the matching web Board operations; do not substitute a generic move or direct Markdown -edit to imitate an unavailable typed operation. +from the response. A recommendation is advice, not execution authority. + +## Individual workflow actions + +For an authorized Queue, Send back, or Resume request, inspect the selected action and its +consequence with `wrighty actions --json`. On Local Markdown, these actions report +`execution: "supported"`. Explain any automatic-processing consequence if the user's request has +not already authorized it; do not add another confirmation once that exact effect is authorized. +Then execute the stable name with the returned `result.stateVersion`: + +```shell +wrighty actions queue --exec --yes --expected-version --json +``` + +Use `send-back` or `resume` for those intents. Queue authorizes automatic processing when the +worker-queue policy is enabled; Send back revokes that authorization. With that policy disabled, +execution authorization stays independent. Resume queues the recorded session and preserves the +requirements, context, and execution selection; it does not start a worker. Interactive +`resume-session` is a different action and remains manual-only in this catalogue. + +The command revalidates current state under the backend's mutation lock. On `ACTION_STATE_CHANGED`, +claim contention, missing session, or backend refusal, inspect again and report the specific reason. +Do not force takeover, substitute another action, or imitate these operations with a generic +status move or direct Markdown edit. These executors currently support Local Markdown only. + +Read `result.outcome`, `before`, and `after` to report the applied transition, then use `workers` +for refreshed pickup prospects. An applied result with `refreshError` means the mutation succeeded +but worker assessment failed: inspect again without replaying the mutation. If a command fails +without a definitive outcome, re-read the item before any retry. Other catalogue entries remain +manual-only; use their documented focused procedure only within the user's authorization. ## Workers and pickup prospects diff --git a/src/Highbyte.Wrighty.Cli/CliActionCommands.cs b/src/Highbyte.Wrighty.Cli/CliActionCommands.cs index ede3258..9f6bd3e 100644 --- a/src/Highbyte.Wrighty.Cli/CliActionCommands.cs +++ b/src/Highbyte.Wrighty.Cli/CliActionCommands.cs @@ -16,27 +16,87 @@ private Command BuildActionsCommand() var name = new Argument("action-name") { Arity = ArgumentArity.ZeroOrOne }; var all = new Option("--all") { Description = "Include unavailable actions and their reasons." }; var json = JsonOption(); - var exec = new Option("--exec") { Description = "Reserved; action execution is not supported yet." }; - var command = new Command("actions", "Discover the actions available for a work item (read-only)"); + var exec = new Option("--exec") { Description = "Execute one supported workflow action after revalidation." }; + var yes = new Option("--yes") { Description = "Authorize the selected action without prompting." }; + var expected = new Option("--expected-version") { Description = "Require the state version returned by action discovery." }; + var command = new Command("actions", "Discover or explicitly execute a work item action"); command.Arguments.Add(id); command.Arguments.Add(name); command.Options.Add(all); command.Options.Add(json); command.Options.Add(exec); - command.SetAction((parsed, cancellationToken) => ExecuteAsync(parsed.GetValue(json), async config => - { - if (parsed.GetValue(exec)) - throw new TrackerException("ACTION_EXECUTION_UNSUPPORTED", - "Action discovery is read-only; use the documented focused command after review.", 2); - var itemId = tracker.ResolveId(config, parsed.GetValue(id)!); - var state = await tracker.GetOperationalAsync(config, itemId, cancellationToken); - var discovery = await DiscoverActionsAsync(config, state, cancellationToken); - var shown = SelectActions(discovery, parsed.GetValue(name), parsed.GetValue(all)); - await writer.WriteActionsAsync(shown, parsed.GetValue(json)); - }, cancellationToken)); + command.Options.Add(yes); + command.Options.Add(expected); + command.SetAction((parsed, cancellationToken) => ExecuteAsync(parsed.GetValue(json), + config => RunActionCommandAsync(config, new ActionCommandRequest( + parsed.GetValue(id)!, parsed.GetValue(name), parsed.GetValue(all), parsed.GetValue(exec), + parsed.GetValue(yes), parsed.GetValue(expected), parsed.GetValue(json)), + cancellationToken), cancellationToken)); return command; } + private sealed record ActionCommandRequest( + string Id, string? Name, bool All, bool Execute, bool Yes, string? ExpectedVersion, bool Json); + + private async Task RunActionCommandAsync(TrackerConfig config, ActionCommandRequest request, + CancellationToken cancellationToken) + { + var (id, selected, all, execute, yes, expectedVersion, json) = request; + ValidateActionOptions(selected, all, execute, yes, expectedVersion); + var itemId = tracker.ResolveId(config, id); + var state = await tracker.GetOperationalAsync(config, itemId, cancellationToken); + var discovery = await DiscoverActionsAsync(config, state, cancellationToken); + var shown = SelectActions(discovery, selected, all); + if (!execute) + { + await writer.WriteActionsAsync(shown, json); + return; + } + await ConfirmWorkflowActionAsync(shown.Actions.Single(), yes, json, cancellationToken); + config = await configLoader.LoadAsync(workingDirectory, cancellationToken); + var result = await new WorkflowActionService(tracker).ExecuteAsync(config, itemId, selected!, + expectedVersion ?? discovery.StateVersion, cancellationToken); + await WriteExecutedActionAsync(config, result, json, cancellationToken); + } + + private static void ValidateActionOptions(string? selected, bool all, bool execute, bool yes, string? expectedVersion) + { + if (execute && (selected is null || all)) + throw new TrackerException("ARGUMENT_INVALID", "--exec requires one action name and cannot use --all.", 2); + if (!execute && (yes || expectedVersion is not null)) + throw new TrackerException("ARGUMENT_INVALID", "--yes and --expected-version require --exec.", 2); + if (execute) WorkflowActionService.EnsureSupported(selected!); + } + + private async Task WriteExecutedActionAsync(TrackerConfig config, WorkflowActionResult result, bool json, + CancellationToken cancellationToken) + { + WorkerDiscovery? workers = null; + string? refreshError = null; + try { workers = await ReadWorkersAsync(config, result.ItemId, cancellationToken); } + catch (Exception exception) when (exception is TrackerException or IOException or UnauthorizedAccessException or OperationCanceledException) + { + // The mutation succeeded. Failed follow-up inspection must not invite replay. + refreshError = "WORKER_REFRESH_UNAVAILABLE"; + } + await writer.WriteWorkflowActionAsync(result, workers, refreshError, json); + } + + private async Task ConfirmWorkflowActionAsync(OperationalAction action, bool yes, bool json, + CancellationToken cancellationToken) + { + if (yes) return; + if (json || isInputRedirected()) + throw new TrackerException("ACTION_CONFIRMATION_REQUIRED", + $"{action.Description} Pass --yes to authorize this operation.", 2); + await output.WriteLineAsync(action.Description); + await output.WriteAsync($"Confirm {action.Title}? [y/N] "); + var answer = await input.ReadLineAsync(cancellationToken); + if (!string.Equals(answer, "y", StringComparison.OrdinalIgnoreCase) && + !string.Equals(answer, "yes", StringComparison.OrdinalIgnoreCase)) + throw new TrackerException("ACTION_CONFIRMATION_REQUIRED", "The action was cancelled.", 2); + } + private static OperationalActionDiscovery SelectActions( OperationalActionDiscovery discovery, string? selected, bool all) { diff --git a/src/Highbyte.Wrighty.Cli/CliWorkerDiscoveryCommands.cs b/src/Highbyte.Wrighty.Cli/CliWorkerDiscoveryCommands.cs index a46a142..6801d8e 100644 --- a/src/Highbyte.Wrighty.Cli/CliWorkerDiscoveryCommands.cs +++ b/src/Highbyte.Wrighty.Cli/CliWorkerDiscoveryCommands.cs @@ -21,6 +21,10 @@ private Command BuildWorkersCommand() private async Task InspectWorkersAsync(TrackerConfig config, string? item, bool json, CancellationToken cancellationToken) + => await writer.WriteWorkersAsync(await ReadWorkersAsync(config, item, cancellationToken), json); + + private async Task ReadWorkersAsync(TrackerConfig config, string? item, + CancellationToken cancellationToken) { var configurationPath = config.SourcePath ?? Path.Combine(workingDirectory, TrackerConfigLoader.FileName); var snapshot = await workerInstances.InspectAsync(configurationPath, cancellationToken); @@ -39,7 +43,7 @@ private async Task InspectWorkersAsync(TrackerConfig config, string? item, bool snapshot.ObservedAt, cancellationToken); workers.Add(WorkerDiscoveryEntry.From(status, revision, pickup)); } - await writer.WriteWorkersAsync(new(snapshot.ObservedAt, snapshot.ConfigurationPathHash, - snapshot.Coverage, snapshot.Detail, revision, state?.Item.Id.Value, workers), json); + return new(snapshot.ObservedAt, snapshot.ConfigurationPathHash, + snapshot.Coverage, snapshot.Detail, revision, state?.Item.Id.Value, workers); } } diff --git a/src/Highbyte.Wrighty.Cli/Output/ActionOutput.cs b/src/Highbyte.Wrighty.Cli/Output/ActionOutput.cs index 695a7bf..2b9682d 100644 --- a/src/Highbyte.Wrighty.Cli/Output/ActionOutput.cs +++ b/src/Highbyte.Wrighty.Cli/Output/ActionOutput.cs @@ -1,4 +1,5 @@ using Highbyte.Wrighty.Actions; +using Highbyte.Wrighty.Workers; namespace Highbyte.Wrighty.Cli.Output; @@ -19,6 +20,22 @@ public async Task WriteActionsAsync(OperationalActionDiscovery discovery, bool j await WriteActionAsync(action); } + public async Task WriteWorkflowActionAsync(WorkflowActionResult result, WorkerDiscovery? workers, + string? refreshError, bool json) + { + if (json) + { + await WriteJsonAsync(new { schemaVersion = 1, result, workers, refreshError }); + return; + } + await output.WriteLineAsync($"{result.Action} applied to {result.ItemId}: " + + $"{result.Before.Status} → {result.After.Status}; {result.After.OperationalStatus}."); + await output.WriteLineAsync($"Automatic execution: {result.After.AutomaticExecutionAllowed}. No worker started."); + if (workers is not null) await WriteWorkersAsync(workers, false); + if (refreshError is not null) + await output.WriteLineAsync("Action applied; worker assessment could not be refreshed. Inspect before retrying."); + } + private async Task WriteActionAsync(OperationalAction action) { await output.WriteLineAsync(); diff --git a/src/Highbyte.Wrighty.Core/Actions/OperationalAction.cs b/src/Highbyte.Wrighty.Core/Actions/OperationalAction.cs index 113b33e..e574602 100644 --- a/src/Highbyte.Wrighty.Core/Actions/OperationalAction.cs +++ b/src/Highbyte.Wrighty.Core/Actions/OperationalAction.cs @@ -19,8 +19,7 @@ public sealed record OperationalAction( string? AgentPrompt = null, bool Recommended = false) { - // The foundation deliberately has no executors, including for otherwise available actions. - public string Execution { get; } = "manual-only"; + public string Execution { get; init; } = "manual-only"; public static OperationalAction FromGuidance( WorkerOperatorAction guidance, @@ -48,4 +47,5 @@ public sealed record OperationalActionDiscovery( string ItemId, DateTimeOffset StateObservedAt, string? RecommendedAction, - IReadOnlyList Actions); + IReadOnlyList Actions, + string? StateVersion = null); diff --git a/src/Highbyte.Wrighty.Core/Actions/OperationalActionResolver.cs b/src/Highbyte.Wrighty.Core/Actions/OperationalActionResolver.cs index bda2eb6..84c94ac 100644 --- a/src/Highbyte.Wrighty.Core/Actions/OperationalActionResolver.cs +++ b/src/Highbyte.Wrighty.Core/Actions/OperationalActionResolver.cs @@ -42,7 +42,7 @@ public static OperationalActionDiscovery Resolve(OperationalActionContext contex surface.HasDiscussion ? "url" : "local-process", startsProcess: !surface.HasDiscussion) ]; AddClarificationActions(context, surface, guidance, actions); - AddBoardActions(context, actions); + actions.AddRange(BoardActions(context)); var workerAvailability = WorkerAvailability(context); if (state.OperationalStatus != OperationalStatuses.RetryScheduled) actions.Add(OperationalAction.FromGuidance( @@ -69,7 +69,8 @@ public static OperationalActionDiscovery Resolve(OperationalActionContext contex : null; return new(id.Value, context.ObservedAt, recommended, actions.Select(value => value with { Recommended = value.Name == recommended }) - .OrderByDescending(value => value.Recommended).ToArray()); + .OrderByDescending(value => value.Recommended).ToArray(), + WorkflowActionService.Version(config, state)); } private static void AddClarificationActions( @@ -102,8 +103,9 @@ private static void AddClarificationActions( } } - private static void AddBoardActions(OperationalActionContext context, List actions) + public static IReadOnlyList BoardActions(OperationalActionContext context) { + List actions = []; var (config, state) = (context.Config, context.State); var common = BoardAvailability(config, state); var untouched = FirstBlocked(common, UntouchedAvailability(state)); @@ -121,6 +123,7 @@ private static void AddBoardActions(OperationalActionContext context, List OperationalAction.FromGuidance( - new WorkerOperatorAction(title, [], description + " Use the corresponding Board action in wrighty web.", - Name: name), availability, confirmation: ConfirmationRequired); + new WorkerOperatorAction(title, [], description, Name: name), availability, + confirmation: ConfirmationRequired) with { Execution = "supported" }; private static ActionAvailability EditAvailability(WorkItemOperationalState state) { diff --git a/src/Highbyte.Wrighty.Core/Actions/WorkflowActionService.cs b/src/Highbyte.Wrighty.Core/Actions/WorkflowActionService.cs new file mode 100644 index 0000000..c6c1564 --- /dev/null +++ b/src/Highbyte.Wrighty.Core/Actions/WorkflowActionService.cs @@ -0,0 +1,78 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Highbyte.Wrighty.Configuration; +using Highbyte.Wrighty.Errors; +using Highbyte.Wrighty.Models; + +namespace Highbyte.Wrighty.Actions; + +public sealed record WorkflowActionState( + string? Status, string OperationalStatus, bool AutomaticExecutionAllowed, string? DispatchState); + +public sealed record WorkflowActionResult( + string ItemId, string Action, string Outcome, DateTimeOffset ObservedAt, + string StateVersion, WorkflowActionState Before, WorkflowActionState After) +{ + public bool StartsWorker { get; } = false; +} + +/// Implemented only by backends that can revalidate and mutate under one authority boundary. +public interface IWorkflowActionBackend +{ + Task ExecuteWorkflowActionAsync( + TrackerConfig config, WorkItemId id, string action, string expectedVersion, + CancellationToken cancellationToken); +} + +public sealed class WorkflowActionService(TrackerService tracker) +{ + public async Task ExecuteAsync( + TrackerConfig config, WorkItemId id, string action, string? expectedVersion, + CancellationToken cancellationToken) + { + EnsureSupported(action); + if (tracker.Backend(config) is not IWorkflowActionBackend backend) + throw new TrackerException("NOT_SUPPORTED", "This backend does not support board workflow execution.", 3); + var current = await tracker.GetOperationalAsync(config, id, cancellationToken); + return await backend.ExecuteWorkflowActionAsync( + config, id, action, expectedVersion ?? Version(config, current), cancellationToken); + } + + public static void EnsureSupported(string action) + { + if (action is not ("queue" or "send-back" or "resume")) + throw new TrackerException("ACTION_EXECUTION_UNSUPPORTED", + "Only queue, send-back, and resume support workflow execution.", 2); + } + + public static OperationalAction Select(TrackerConfig config, WorkItemOperationalState state, string action) + { + EnsureSupported(action); + return OperationalActionResolver.BoardActions(new(config, state, DateTimeOffset.UtcNow, + state.Session?.WorkspacePath is { } path && Directory.Exists(path))) + .Single(value => value.Name == action); + } + + public static void Validate(TrackerConfig config, WorkItemOperationalState state, + string action, string expectedVersion) + { + var selected = Select(config, state, action); + if (selected.UnavailableCode is { } code) + throw new TrackerException(code, selected.UnavailableReason!, 6); + if (!string.Equals(expectedVersion, Version(config, state), StringComparison.Ordinal)) + throw new TrackerException("ACTION_STATE_CHANGED", + "The item, claim, session, or configuration changed. Inspect the action again before retrying.", 6); + } + + // An observation fingerprint detects changes; it contains no credentials and grants no authority. + public static string Version(TrackerConfig config, WorkItemOperationalState state) => + Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(new { config, state }))); + + public static WorkflowActionState Describe(WorkItemOperationalState state) => new( + state.Item.Status, state.OperationalStatus, state.Item.AutomaticExecutionAllowed, state.Item.DispatchState); + + public static WorkItemOperationalState Operational(TrackerConfig config, WorkItemOperationalSnapshot snapshot) => new( + snapshot.Item, snapshot.Claim, snapshot.Session, + OperationalStatuses.Resolve(snapshot.Item, snapshot.Claim, snapshot.Session, + config.DefaultPickFrom, config.DefaultFinishTo)); +} diff --git a/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalMarkdownTrackerBackend.WorkflowActions.cs b/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalMarkdownTrackerBackend.WorkflowActions.cs new file mode 100644 index 0000000..cd9d1ee --- /dev/null +++ b/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalMarkdownTrackerBackend.WorkflowActions.cs @@ -0,0 +1,47 @@ +using Highbyte.Wrighty.Actions; +using Highbyte.Wrighty.Configuration; +using Highbyte.Wrighty.Models; + +namespace Highbyte.Wrighty.LocalMarkdown; + +public sealed partial class LocalMarkdownTrackerBackend +{ + public async Task ExecuteWorkflowActionAsync( + TrackerConfig config, WorkItemId id, string action, string expectedVersion, + CancellationToken cancellationToken) + { + WorkflowActionService.EnsureSupported(action); + EnsureStore(config); + var paths = Paths(config); + await using var storeLock = await LocalStoreLock.AcquireAsync(paths.Root, cancellationToken); + await PauseAfterLockAsync("workflow-action", cancellationToken); + var document = await RequiredUnlockedAsync(config, id, cancellationToken); + var runtime = await LocalRuntimeStateStore.LoadUnlockedAsync(paths.Root, cancellationToken); + var installation = await identityProvider.GetInstallationIdAsync(cancellationToken); + var before = WorkflowActionService.Operational(config, Snapshot(document, runtime, installation, clock.UtcNow)); + WorkflowActionService.Validate(config, before, action, expectedVersion); + if (action == "resume") + { + await QueuePausedUnlockedAsync(config, id, paths, document, cancellationToken); + runtime = await LocalRuntimeStateStore.LoadUnlockedAsync(paths.Root, cancellationToken); + } + else + { + var target = action == "queue" ? config.DefaultPickFrom + : WorkflowStatusPolicy.InferBacklogStatus(config, config.LocalMarkdown!.Statuses)!; + var patch = WorkerQueuePolicy.Apply(config, document.Status, null, WorkItemPatch.StatusOnly(target)).Patch; + if (patch.AutomaticExecutionAllowed is { IsSpecified: true, Value: false }) + patch = patch with { DispatchState = OptionalValue.From(null) }; + WorkItemPatchValidator.Validate(patch, supportedAgentIds); + List changed = []; + ApplyPatch(config, document, new(patch, config.ShouldArchiveStatus(target)), changed); + var originalPath = document.Path; + document.UpdatedAt = clock.UtcNow; + document.Path = CanonicalPath(config, document); + await WriteUnlockedAsync(document, originalPath, cancellationToken); + } + var after = WorkflowActionService.Operational(config, Snapshot(document, runtime, installation, clock.UtcNow)); + return new(id.Value, action, "applied", clock.UtcNow, WorkflowActionService.Version(config, after), + WorkflowActionService.Describe(before), WorkflowActionService.Describe(after)); + } +} diff --git a/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalMarkdownTrackerBackend.cs b/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalMarkdownTrackerBackend.cs index 4a0ba19..d5c7ccd 100644 --- a/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalMarkdownTrackerBackend.cs +++ b/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalMarkdownTrackerBackend.cs @@ -24,7 +24,8 @@ public sealed partial class LocalMarkdownTrackerBackend( ITrackerBackend, ITrackerDashboardBackend, ILocalMarkdownImportBackend, - IWorkItemDeletionBackend + IWorkItemDeletionBackend, + Highbyte.Wrighty.Actions.IWorkflowActionBackend { private const string GitIgnoreComment = "# Wrighty runtime state"; private static readonly string[] GitIgnoreRules = @@ -1756,6 +1757,13 @@ public async Task QueuePausedAsync( var paths = Paths(config); await using var storeLock = await LocalStoreLock.AcquireAsync(paths.Root, cancellationToken); var document = await RequiredUnlockedAsync(config, id, cancellationToken); + await QueuePausedUnlockedAsync(config, id, paths, document, cancellationToken); + } + + private async Task QueuePausedUnlockedAsync( + TrackerConfig config, WorkItemId id, LocalStorePaths paths, + LocalMarkdownDocument document, CancellationToken cancellationToken) + { if (document.Archived) throw Archived(id); if (!string.Equals(document.DispatchState, DispatchStates.NeedsAttention, diff --git a/src/Highbyte.Wrighty.Core/TrackerService.cs b/src/Highbyte.Wrighty.Core/TrackerService.cs index bc28032..da9b401 100644 --- a/src/Highbyte.Wrighty.Core/TrackerService.cs +++ b/src/Highbyte.Wrighty.Core/TrackerService.cs @@ -245,32 +245,9 @@ private async Task ApplyWorkerQueueRuleAsync( if (current is null) return new WorkerQueueRuleResult(patch, false); // the update itself reports not-found - var entersQueue = IsPickFrom(config, patch.Status.Value) && - !IsPickFrom(config, current.Status); - var leavesQueue = !IsPickFrom(config, patch.Status.Value) && - IsPickFrom(config, current.Status); - if (entersQueue) - { - return new WorkerQueueRuleResult( - patch with { AutomaticExecutionAllowed = OptionalValue.From(true) }, - CycleContextApproval: current.ContextApprovalFieldApproved is not null); - } - if (leavesQueue) - { - return new WorkerQueueRuleResult( - patch with { AutomaticExecutionAllowed = OptionalValue.From(false) }, - false); - } - return new WorkerQueueRuleResult(patch, false); + return WorkerQueuePolicy.Apply(config, current.Status, current.ContextApprovalFieldApproved, patch); } - private readonly record struct WorkerQueueRuleResult( - WorkItemPatch Patch, - bool CycleContextApproval); - - private static bool IsPickFrom(TrackerConfig config, string? status) => - string.Equals(status, config.DefaultPickFrom, StringComparison.OrdinalIgnoreCase); - public Task GetDashboardAsync( TrackerConfig config, ArchiveScope archiveScope, diff --git a/src/Highbyte.Wrighty.Core/WorkerQueuePolicy.cs b/src/Highbyte.Wrighty.Core/WorkerQueuePolicy.cs new file mode 100644 index 0000000..493a21c --- /dev/null +++ b/src/Highbyte.Wrighty.Core/WorkerQueuePolicy.cs @@ -0,0 +1,23 @@ +using Highbyte.Wrighty.Configuration; +using Highbyte.Wrighty.Models; + +namespace Highbyte.Wrighty; + +public readonly record struct WorkerQueueRuleResult(WorkItemPatch Patch, bool CycleContextApproval); + +public static class WorkerQueuePolicy +{ + public static WorkerQueueRuleResult Apply(TrackerConfig config, string? currentStatus, + bool? contextApproval, WorkItemPatch patch) + { + if (!config.EffectiveWorker.UseWorkerQueue || !patch.Status.IsSpecified || + patch.AutomaticExecutionAllowed.IsSpecified) + return new(patch, false); + var wasQueued = string.Equals(currentStatus, config.DefaultPickFrom, StringComparison.OrdinalIgnoreCase); + var willQueue = string.Equals(patch.Status.Value, config.DefaultPickFrom, StringComparison.OrdinalIgnoreCase); + if (wasQueued == willQueue) + return new(patch, false); + return new(patch with { AutomaticExecutionAllowed = OptionalValue.From(willQueue) }, + willQueue && contextApproval is not null); + } +} diff --git a/src/Highbyte.Wrighty.Web/Pages/Index.cshtml.cs b/src/Highbyte.Wrighty.Web/Pages/Index.cshtml.cs index 763e09c..b95a518 100644 --- a/src/Highbyte.Wrighty.Web/Pages/Index.cshtml.cs +++ b/src/Highbyte.Wrighty.Web/Pages/Index.cshtml.cs @@ -1,3 +1,4 @@ +using Highbyte.Wrighty.Actions; using Highbyte.Wrighty.AgentContext; using Highbyte.Wrighty.ApprovedContext; using Highbyte.Wrighty.Backends; @@ -33,6 +34,7 @@ public sealed class IndexModel( private const string WorkerOverviewPartial = "Shared/_WorkerOverview"; private const string QueueActionId = "queue"; private const string DequeueActionId = "dequeue"; + private const string SendBackActionName = "send-back"; private const string ResumeActionId = "resume"; private static readonly JsonSerializerOptions IndentedJson = new() { WriteIndented = true }; private readonly BoardBatchStore boardBatches = state.BoardBatches; @@ -2941,10 +2943,8 @@ await tracker.ReleaseAsync(state.Config, resolved, } /// - /// The board's one-click queue action: claim, move to the pick-from status, release. The - /// status move runs through the tracker service, so with the worker queue enabled the move - /// authorizes execution and, on GitHub, context approval — the button is the whole "give this - /// to the worker" ceremony. + /// The board's one-click queue action uses the same locked eligibility check and workflow + /// operation as the CLI. With the worker queue enabled, the move authorizes execution. /// public async Task OnPostQueueItemAsync( string id, @@ -2967,33 +2967,8 @@ public async Task OnPostQueueItemAsync( private async Task QueueItemAsync(string id, CancellationToken cancellationToken) { - var resolved = tracker.ResolveId(state.Config, id); - var claim = await tracker.ClaimAsync( - state.Config, resolved, state.ClaimantContext, cancellationToken); - var handle = new ClaimHandle( - state.ClaimantContext with { ClaimantId = claim.ClaimantId }, - claim.ClaimToken); - try - { - await tracker.UpdateAsync( - state.Config, - resolved, - WorkItemPatch.StatusOnly(state.Config.DefaultPickFrom), - expectedRevision: null, - handle, - cancellationToken); - } - catch (TrackerException) - { - // The claim was only scaffolding for this one move; do not leave the item claimed - // behind a failed update. A failing release must not mask the update error. - await ReleaseScaffoldingClaimAsync(resolved, handle, cancellationToken); - throw; - } - - await tracker.ReleaseAsync( - state.Config, resolved, handle, false, DispatchStateOnRelease.Preserve, - cancellationToken); + await new WorkflowActionService(tracker).ExecuteAsync(state.Config, + tracker.ResolveId(state.Config, id), QueueActionId, null, cancellationToken); } // Both sides are nullable: a work item's status may be absent, and so may the status it is @@ -3034,24 +3009,19 @@ private bool IsUnmanagedTerminal( /// Most states offer one action, because a board's value is scanning and a toolbar per card /// destroys that. Needs-attention is the exception: there the next step genuinely branches — /// answer the question, or hand it back — so it carries a small set. The list shape is what - /// plan 036's catalogue will fill later. + /// the shared catalogue supplies for migrated workflow actions. /// private IReadOnlyList CardActions( DashboardWorkItem value, - string activity, - IReadOnlyList statuses) + string activity) { if (value.Item.Archived) return []; - // Queueable: an untouched backlog item — unclaimed, no recovery state, and not already in - // the queue, in progress, or finished. For the default statuses that is exactly the first - // (backlog) column. - if (value.Claim.State == ClaimOwnershipState.Unclaimed && - value.Item.DispatchState is null && - !IsWorkflowStatus(value.Item.Status, state.Config.DefaultPickFrom) && - !IsWorkflowStatus(value.Item.Status, state.Config.DefaultPickTo) && - !IsWorkflowStatus(value.Item.Status, state.Config.DefaultFinishTo)) + var boardActions = BoardWorkflowActions(value); + var queue = boardActions.Single(action => action.Name == QueueActionId); + var sendBack = boardActions.Single(action => action.Name == SendBackActionName); + if (queue.Availability == "available") { // Two next moves, so two actions — the same shape as a needs-attention card. Editing // an item you can see took opening the panel and then claiming; the panel's claim @@ -3065,7 +3035,7 @@ value.Item.DispatchState is null && QueueActionId, "QueueItem", "Queue", - "Move to the worker queue so an agent can pick it up", + queue.Description, "for agent"), new CardActionView( "edit", @@ -3081,24 +3051,17 @@ value.Item.DispatchState is null && // In the queue and untouched: the symmetric revocation. Once a worker has claimed it or // recorded recovery state, taking it back is no longer a one-gesture decision. - if (IsWorkflowStatus(value.Item.Status, state.Config.DefaultPickFrom) && - value.Claim.State == ClaimOwnershipState.Unclaimed && - value.Item.DispatchState is null) + if (sendBack.Availability == "available" || sendBack.UnavailableCode == "STATUS_UNAVAILABLE") { - var backlog = BacklogStatus(statuses); return [ new CardActionView( DequeueActionId, "DequeueItem", "Send back", - backlog is null - ? "Move out of the worker queue; the queue rule revokes automatic execution" - : $"Move back to {backlog}; the worker queue rule revokes automatic execution", + sendBack.Description, "out of the worker queue", - UnavailableReason: backlog is null - ? "No backlog status is configured to send this item back to." - : null) + UnavailableReason: sendBack.UnavailableReason) ]; } @@ -3123,6 +3086,17 @@ backlog is null return DispatchMarkerActions(value, activity); } + private IReadOnlyList BoardWorkflowActions(DashboardWorkItem value) + { + var item = value.Item; + var detail = new WorkItemDetail(item.Id, item.Title, "", item.Url, item.Status, item.Priority, + Archived: item.Archived, AutomaticExecutionAllowed: item.AutomaticExecutionAllowed, + DispatchState: item.DispatchState); + var operational = WorkflowActionService.Operational(state.Config, new(detail, value.Claim, value.Session)); + return OperationalActionResolver.BoardActions(new(state.Config, operational, DateTimeOffset.UtcNow, + value.Session?.WorkspacePath is { } path && Directory.Exists(path))); + } + private IReadOnlyList NeedsAttentionCardActions( DashboardWorkItem value, AgentSessionRecord session) @@ -3160,13 +3134,15 @@ private IReadOnlyList NeedsAttentionCardActions( "session will remain available to resume afterward." : null, ConfirmAction: requiresConfirmedTakeover ? "Open for clarification" : null)); + var resume = BoardWorkflowActions(value).Single(action => action.Name == ResumeActionId); actions.Add(new CardActionView( ResumeActionId, "ResumeSession", "Resume", - "Queue the recorded session so a continuous worker resumes it", + resume.Description, "recorded session", - IsPrimary: false)); + IsPrimary: false, + UnavailableReason: resume.UnavailableReason)); } actions.AddRange(LaunchCardActions(value, session)); @@ -3517,6 +3493,19 @@ public async Task OnPostMoveItemAsync( $"only '{state.Config.DefaultFinishTo}' may end. Use the item's own " + "actions instead.", 6); + string? workflowAction = null; + if (IsWorkflowStatus(target, state.Config.DefaultPickFrom)) + workflowAction = QueueActionId; + else if (IsWorkflowStatus(card.Item.Status, state.Config.DefaultPickFrom) && + IsWorkflowStatus(target, BacklogStatus(snapshot.Statuses))) + workflowAction = SendBackActionName; + if (workflowAction is not null) + { + await new WorkflowActionService(tracker).ExecuteAsync( + state.Config, resolved, workflowAction, null, cancellationToken); + Response.Headers["HX-Trigger"] = "wrighty:refresh"; + return new NoContentResult(); + } var claim = await tracker.ClaimAsync( state.Config, resolved, state.ClaimantContext, cancellationToken); var handle = new ClaimHandle( @@ -3575,41 +3564,8 @@ public async Task OnPostDequeueItemAsync( private async Task DequeueItemAsync(string id, CancellationToken cancellationToken) { - var resolved = tracker.ResolveId(state.Config, id); - var snapshot = await tracker.GetDashboardAsync( - state.Config, ArchiveScope.Active, cancellationToken); - var backlog = BacklogStatus(snapshot.Statuses) - ?? throw new TrackerException( - "STATUS_UNAVAILABLE", - "No backlog status is configured to send this item back to.", - 2); - var claim = await tracker.ClaimAsync( - state.Config, resolved, state.ClaimantContext, cancellationToken); - var handle = new ClaimHandle( - state.ClaimantContext with { ClaimantId = claim.ClaimantId }, - claim.ClaimToken); - try - { - await tracker.UpdateAsync( - state.Config, - resolved, - WorkItemPatch.StatusOnly(backlog), - expectedRevision: null, - handle, - cancellationToken); - } - catch (TrackerException) - { - // Same discipline as the queue button: the claim was scaffolding for one move, so - // a failed move must not leave the item claimed, and a failing release must not - // mask the move's error. - await ReleaseScaffoldingClaimAsync(resolved, handle, cancellationToken); - throw; - } - - await tracker.ReleaseAsync( - state.Config, resolved, handle, false, DispatchStateOnRelease.Preserve, - cancellationToken); + await new WorkflowActionService(tracker).ExecuteAsync(state.Config, + tracker.ResolveId(state.Config, id), SendBackActionName, null, cancellationToken); } /// @@ -3638,7 +3594,8 @@ public async Task OnPostResumeSessionAsync( private async Task ResumeSessionAsync(string id, CancellationToken cancellationToken) { var resolved = tracker.ResolveId(state.Config, id); - await tracker.QueuePausedAsync(state.Config, resolved, cancellationToken); + await new WorkflowActionService(tracker).ExecuteAsync( + state.Config, resolved, ResumeActionId, null, cancellationToken); state.Forget(resolved.Value); } @@ -3783,7 +3740,7 @@ private async Task BoardBatchCandidateStillEligibleAsync( item.Session, state.Config.DefaultFinishTo); var actionId = BoardBatchActionId(action); - return CardActions(item, activity, snapshot.Statuses).Any(value => + return CardActions(item, activity).Any(value => value.IsAvailable && string.Equals(value.Id, actionId, StringComparison.Ordinal)); } @@ -3799,7 +3756,8 @@ private Task ExecuteBoardBatchItemAsync( private static bool IsBoardBatchItemConflict(TrackerException exception) => exception.Code is "WORK_ITEM_NOT_FOUND" or "WORK_ITEM_ARCHIVED" or - "CLAIM_NOT_OWNER" or "WORKER_ITEM_INELIGIBLE" or + "CLAIM_NOT_OWNER" or "WORKER_ITEM_INELIGIBLE" or "ACTION_STATE_CHANGED" or + "WORKFLOW_STATE_INVALID" or "WORKER_RECOVERY_PENDING" or "ITEM_ARCHIVED" or "RESUME_ADDRESS_NOT_LOCAL" || Status(exception) == StatusCodes.Status409Conflict; @@ -3870,7 +3828,8 @@ public async Task OnPostQueueForWorkerAsync( try { var resolved = tracker.ResolveId(state.Config, id); - await tracker.QueuePausedAsync(state.Config, resolved, cancellationToken); + await new WorkflowActionService(tracker).ExecuteAsync( + state.Config, resolved, ResumeActionId, null, cancellationToken); state.Forget(resolved.Value); Response.Headers["HX-Trigger"] = "wrighty:refresh"; return Partial( @@ -4311,14 +4270,8 @@ private async Task Item( WorkItemDeletionPolicy.HasProcessingHistory(operational.Session)).CanDelete; var lastRun = LastRunView.From(session); var providerBlock = await ProviderBlockAsync(item, activity, cancellationToken); - var canQueueForWorker = - !item.Archived && - activity == OperationalStatuses.NeedsAttention && - editable.Claim.State != ClaimOwnershipState.HeldByOther && - item.AutomaticExecutionAllowed && - string.Equals(item.Status, state.Config.DefaultPickTo, - StringComparison.OrdinalIgnoreCase) && - session is { IsComplete: true, FromCurrentInstallation: true }; + var canQueueForWorker = WorkflowActionService.Select(state.Config, operational, ResumeActionId) + .Availability == "available"; string? claimProtectionNotice = null; if (webMutationProtected) { @@ -4999,7 +4952,7 @@ agent is not null && activity, value.HasRecordedWorktree, providerBlock, - CardActions(value, activity, snapshot.Statuses), + CardActions(value, activity), DropTargets(value, snapshot.Statuses), value.Item.CreatedAt, value.Item.UpdatedAt, diff --git a/tests/Highbyte.Wrighty.UnitTests/Actions/OperationalActionResolverTests.cs b/tests/Highbyte.Wrighty.UnitTests/Actions/OperationalActionResolverTests.cs index 89ca79e..1649abb 100644 --- a/tests/Highbyte.Wrighty.UnitTests/Actions/OperationalActionResolverTests.cs +++ b/tests/Highbyte.Wrighty.UnitTests/Actions/OperationalActionResolverTests.cs @@ -149,7 +149,9 @@ public void Deferred_work_never_recommends_overriding_the_schedule(string status var result = Resolve(Paused() with { OperationalStatus = status }); Assert.Null(result.RecommendedAction); Assert.Equal("available", Find(result, action).Availability); - Assert.All(result.Actions, value => Assert.Equal("manual-only", value.Execution)); + Assert.Equal("manual-only", Find(result, action).Execution); + Assert.All(result.Actions.Where(value => value.Execution == "supported"), + value => Assert.True(value.Name is "queue" or "send-back" or "resume")); Assert.Equal(result.Actions.Count, result.Actions.Select(value => value.Name).Distinct().Count()); } diff --git a/tests/Highbyte.Wrighty.UnitTests/Cli/ActionCommandTests.cs b/tests/Highbyte.Wrighty.UnitTests/Cli/ActionCommandTests.cs index a2bcd2e..b04f0a2 100644 --- a/tests/Highbyte.Wrighty.UnitTests/Cli/ActionCommandTests.cs +++ b/tests/Highbyte.Wrighty.UnitTests/Cli/ActionCommandTests.cs @@ -83,7 +83,8 @@ public async Task Execution_is_refused_before_any_work_is_attempted(bool selecte : ["actions", "42", "--exec", "--json"]; Assert.Equal(2, await Application(backend, new StringReader("yes"), output, error).InvokeAsync(args)); using var json = JsonDocument.Parse(error.ToString()); - Assert.Equal("ACTION_EXECUTION_UNSUPPORTED", json.RootElement.GetProperty("error").GetProperty("code").GetString()); + Assert.Equal(selected ? "ACTION_EXECUTION_UNSUPPORTED" : "ARGUMENT_INVALID", + json.RootElement.GetProperty("error").GetProperty("code").GetString()); Assert.Null(backend.Patch); } diff --git a/tests/Highbyte.Wrighty.UnitTests/Cli/CliApplicationTests.cs b/tests/Highbyte.Wrighty.UnitTests/Cli/CliApplicationTests.cs index 9e8129f..5a378c4 100644 --- a/tests/Highbyte.Wrighty.UnitTests/Cli/CliApplicationTests.cs +++ b/tests/Highbyte.Wrighty.UnitTests/Cli/CliApplicationTests.cs @@ -4292,7 +4292,8 @@ private static CliApplication Application( StorageLocationCatalog? storageLocationCatalog = null, AgentRegistry? agentRegistry = null, IWebSkillMaintenance? skillMaintenance = null, - ILogger? logger = null) + ILogger? logger = null, + TrackerService? trackerOverride = null) { var effectiveAgentRegistry = agentRegistry ?? BuiltInAgentRegistry.Create( new PathExecutableResolver()); @@ -4304,7 +4305,7 @@ private static CliApplication Application( claims, resolver, backend); - var tracker = new TrackerService( + var tracker = trackerOverride ?? new TrackerService( new TrackerBackendRegistry([trackerBackend]), effectiveAgentRegistry); return new CliApplication( diff --git a/tests/Highbyte.Wrighty.UnitTests/Cli/WorkflowActionCommandTests.cs b/tests/Highbyte.Wrighty.UnitTests/Cli/WorkflowActionCommandTests.cs new file mode 100644 index 0000000..3942b43 --- /dev/null +++ b/tests/Highbyte.Wrighty.UnitTests/Cli/WorkflowActionCommandTests.cs @@ -0,0 +1,141 @@ +using System.Text.Json; +using Highbyte.Wrighty.Backends; +using Highbyte.Wrighty.Claims; +using Highbyte.Wrighty.Configuration; +using Highbyte.Wrighty.Identity; +using Highbyte.Wrighty.LocalMarkdown; +using Highbyte.Wrighty.Models; +using Highbyte.Wrighty.Time; +using Highbyte.Wrighty.Workers; + +namespace Highbyte.Wrighty.UnitTests.Cli; + +public sealed partial class CliApplicationTests +{ + private async Task<(TrackerConfig Config, TrackerService Tracker, WorkItemId Id)> WorkflowCliFixture() + { + var root = Path.Combine(Path.GetTempPath(), "wrighty-workflow-cli-" + Guid.NewGuid().ToString("N")); + temporarySettingsRoots.Add(root); + Directory.CreateDirectory(root); + var config = new TrackerConfig + { + Backend = "local-markdown", SourcePath = Path.Combine(root, ".wrighty.json"), + DefaultPickFrom = "Automation", DefaultPickTo = "Doing", DefaultFinishTo = "Complete", + LocalMarkdown = new() { Statuses = ["Ideas", "Automation", "Doing", "Complete"] } + }; + var backend = new LocalMarkdownTrackerBackend(new WorkflowIdentity(), new SystemClock()); + await backend.InitializeAsync(config, false, default); + var created = await backend.CreateAsync(config, new CreateWorkItemOperation(new("CLI workflow", "Body", "Ideas", "P1"), false), default); + return (config, new(new TrackerBackendRegistry([backend])), created.Id); + } + + [Fact] + public async Task Workflow_cli_executes_reviewed_queue_and_send_back_with_structured_results() + { + var f = await WorkflowCliFixture(); + var output = new StringWriter(); + var error = new StringWriter(); + var app = Application(new RecordingBackend(), new StringReader(""), output, error, + config: f.Config, trackerOverride: f.Tracker, inputRedirected: true); + Assert.Equal(0, await app.InvokeAsync(["actions", "1", "queue", "--json"])); + using var discovery = JsonDocument.Parse(output.ToString()); + var observed = discovery.RootElement.GetProperty("result"); + var version = observed.GetProperty("stateVersion").GetString()!; + Assert.Equal("supported", observed.GetProperty("actions")[0].GetProperty("execution").GetString()); + output.GetStringBuilder().Clear(); + Assert.Equal(0, await app.InvokeAsync(["actions", "1", "queue", "--exec", "--yes", "--expected-version", version, "--json"])); + using var json = JsonDocument.Parse(output.ToString()); + var result = json.RootElement.GetProperty("result"); + Assert.Equal("applied", result.GetProperty("outcome").GetString()); + Assert.Equal("Automation", result.GetProperty("after").GetProperty("status").GetString()); + Assert.True(result.GetProperty("after").GetProperty("automaticExecutionAllowed").GetBoolean()); + Assert.False(result.GetProperty("startsWorker").GetBoolean()); + Assert.Equal("local:1", json.RootElement.GetProperty("workers").GetProperty("itemId").GetString()); + Assert.DoesNotContain("claimToken", output.ToString()); + Assert.Equal("", error.ToString()); + output.GetStringBuilder().Clear(); + Assert.Equal(0, await app.InvokeAsync(["actions", "1", "send-back", "--exec", "--yes"])); + Assert.Contains("send-back applied", output.ToString()); + Assert.Equal("Ideas", (await f.Tracker.GetAsync(f.Config, f.Id, default)).Status); + Assert.Equal(ClaimOwnershipState.Unclaimed, (await f.Tracker.GetClaimOwnershipAsync(f.Config, f.Id, default)).State); + } + + [Theory] + [InlineData("confirmation", "ACTION_CONFIRMATION_REQUIRED")] + [InlineData("stale", "ACTION_STATE_CHANGED")] + [InlineData("all", "ARGUMENT_INVALID")] + [InlineData("discovery-yes", "ARGUMENT_INVALID")] + [InlineData("discovery-version", "ARGUMENT_INVALID")] + public async Task Workflow_cli_refuses_unapproved_or_invalid_requests_without_mutation(string scenario, string code) + { + var f = await WorkflowCliFixture(); + var output = new StringWriter(); + var error = new StringWriter(); + var args = new List { "actions", "1", "queue", "--json" }; + switch (scenario) + { + case "confirmation": args.Add("--exec"); break; + case "stale": args.AddRange(["--exec", "--yes", "--expected-version", "outdated"]); break; + case "all": args.AddRange(["--exec", "--yes", "--all"]); break; + case "discovery-yes": args.Add("--yes"); break; + case "discovery-version": args.AddRange(["--expected-version", "version"]); break; + } + var exit = await Application(new RecordingBackend(), new StringReader(""), output, error, + config: f.Config, trackerOverride: f.Tracker, inputRedirected: true).InvokeAsync(args.ToArray()); + Assert.NotEqual(0, exit); + using var json = JsonDocument.Parse(error.ToString()); + Assert.Equal(code, json.RootElement.GetProperty("error").GetProperty("code").GetString()); + Assert.Equal("Ideas", (await f.Tracker.GetAsync(f.Config, f.Id, default)).Status); + Assert.Equal("", output.ToString()); + } + + [Theory] + [InlineData("yes", 0, "Automation")] + [InlineData("no", 2, "Ideas")] + public async Task Workflow_cli_interactive_confirmation_obeys_the_answer(string answer, int exit, string status) + { + var f = await WorkflowCliFixture(); + var output = new StringWriter(); + Assert.Equal(exit, await Application(new RecordingBackend(), new StringReader(answer), output, + config: f.Config, trackerOverride: f.Tracker).InvokeAsync(["actions", "1", "queue", "--exec"])); + Assert.Contains("authorizes automatic processing", output.ToString()); + Assert.Equal(status, (await f.Tracker.GetAsync(f.Config, f.Id, default)).Status); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Workflow_applied_result_survives_worker_refresh_failure(bool jsonOutput) + { + var f = await WorkflowCliFixture(); + var output = new StringWriter(); + var args = new List { "actions", "1", "queue", "--exec", "--yes" }; + if (jsonOutput) args.Add("--json"); + Assert.Equal(0, await Application(new RecordingBackend(), new StringReader(""), output, + config: f.Config, trackerOverride: f.Tracker, workerInstanceRegistry: new FailedWorkflowRegistry()) + .InvokeAsync(args.ToArray())); + if (jsonOutput) + { + using var result = JsonDocument.Parse(output.ToString()); + Assert.Equal("applied", result.RootElement.GetProperty("result").GetProperty("outcome").GetString()); + Assert.Equal("WORKER_REFRESH_UNAVAILABLE", result.RootElement.GetProperty("refreshError").GetString()); + } + else Assert.Contains("Action applied; worker assessment could not be refreshed", output.ToString()); + Assert.Equal("Automation", (await f.Tracker.GetAsync(f.Config, f.Id, default)).Status); + } + + private sealed class FailedWorkflowRegistry : IWorkerInstanceRegistry + { + public Task InspectAsync(string configurationPath, CancellationToken cancellationToken) => + throw new IOException("Unavailable test registry"); + public Task> ListAsync(string configurationPath, CancellationToken cancellationToken) => + throw new InvalidOperationException("Must use read-only inspection"); + public Task RegisterAsync(string configurationPath, string configurationRevision, + string invocationSummary, CancellationToken cancellationToken) => throw new InvalidOperationException("Must not launch"); + } + + private sealed class WorkflowIdentity : IInstallationIdentityProvider + { + public Task GetInstallationIdAsync(CancellationToken cancellationToken) => Task.FromResult("workflow-cli"); + } +} diff --git a/tests/Highbyte.Wrighty.UnitTests/Web/WorkflowActionParityTests.cs b/tests/Highbyte.Wrighty.UnitTests/Web/WorkflowActionParityTests.cs new file mode 100644 index 0000000..4674eaa --- /dev/null +++ b/tests/Highbyte.Wrighty.UnitTests/Web/WorkflowActionParityTests.cs @@ -0,0 +1,30 @@ +using System.Net; +using Highbyte.Wrighty.Actions; +using Highbyte.Wrighty.Backends; +using Highbyte.Wrighty.Errors; +using System.Text.Json; + +namespace Highbyte.Wrighty.UnitTests.Web; + +public sealed partial class WrightyWebServerTests +{ + [Theory] + [InlineData("QueueItem", "queue")] + [InlineData("DequeueItem", "send-back")] + public async Task Board_workflow_handlers_refuse_a_paused_item_like_the_shared_executor(string handler, string action) + { + var host = await StartServer(openBrowser: false, releaseSeededClaim: true); + using var client = new HttpClient(); + var before = await StoredState(); + var (config, backend, _) = await StoredBackend(); + var tracker = new TrackerService(new TrackerBackendRegistry([backend])); + var error = await Assert.ThrowsAsync(() => new WorkflowActionService(tracker) + .ExecuteAsync(config, before.Item.Id, action, null, default)); + using var response = await PostForm(client, host, handler, new() { ["id"] = before.Item.Id.Value }); + Assert.NotEqual(HttpStatusCode.NoContent, response.StatusCode); + Assert.Contains(error.Code, await response.Content.ReadAsStringAsync()); + var after = await StoredState(); + Assert.Equal(JsonSerializer.Serialize(before), JsonSerializer.Serialize(after)); + await host.Stop(); + } +} diff --git a/tests/Highbyte.Wrighty.UnitTests/Web/WrightyWebServerTests.cs b/tests/Highbyte.Wrighty.UnitTests/Web/WrightyWebServerTests.cs index 5c9ff11..4eb3253 100644 --- a/tests/Highbyte.Wrighty.UnitTests/Web/WrightyWebServerTests.cs +++ b/tests/Highbyte.Wrighty.UnitTests/Web/WrightyWebServerTests.cs @@ -638,6 +638,7 @@ public async Task Hosted_worker_log_is_nested_in_its_worker_card() { var host = await StartServer( openBrowser: false, + pickFrom: "Worker queue", workerConfig: new WorkerConfig { DefaultAgent = "codex", @@ -669,6 +670,11 @@ public async Task Hosted_worker_log_is_nested_in_its_worker_card() savedHtml); Assert.DoesNotContain("configuration-restart-warning", savedHtml); + // This layout test needs an idle worker. Todo contains seeded items and must not + // become the intake queue, otherwise the rejecting process runner can end the run. + var (config, backend, _) = await StoredBackend(); + Assert.Empty(await backend.ListAsync(config, + new ListWorkItemsRequest(config.DefaultPickFrom, null), CancellationToken.None)); var response = await PostForm( client, host, diff --git a/tests/Highbyte.Wrighty.UnitTests/Workers/WorkflowActionExecutionTests.cs b/tests/Highbyte.Wrighty.UnitTests/Workers/WorkflowActionExecutionTests.cs new file mode 100644 index 0000000..af8e193 --- /dev/null +++ b/tests/Highbyte.Wrighty.UnitTests/Workers/WorkflowActionExecutionTests.cs @@ -0,0 +1,123 @@ +using Highbyte.Wrighty.Actions; +using Highbyte.Wrighty.AgentContext; +using Highbyte.Wrighty.Backends; +using Highbyte.Wrighty.Claims; +using Highbyte.Wrighty.Configuration; +using Highbyte.Wrighty.Errors; +using Highbyte.Wrighty.LocalMarkdown; +using Highbyte.Wrighty.Models; + +namespace Highbyte.Wrighty.UnitTests.Workers; + +public sealed partial class LocalDispatchStateTests +{ + private async Task<(LocalMarkdownTrackerBackend Backend, TrackerConfig Config, WorkItemId Id, + TrackerService Tracker, WorkflowActionService Actions)> WorkflowFixture(bool useQueue = true) + { + var config = WorkerConfig() with + { + DefaultPickFrom = "Automation", + LocalMarkdown = new() { Statuses = ["Ideas", "Automation", "In Progress", "Done"] }, + Worker = new() { UseWorkerQueue = useQueue } + }; + var backend = new LocalMarkdownTrackerBackend(new FakeIdentity(), clock); + await backend.InitializeAsync(config, false, default); + var item = await backend.CreateAsync(config, new CreateWorkItemOperation(new("Workflow", "Body", "Ideas", "P1"), false), default); + var tracker = new TrackerService(new TrackerBackendRegistry([backend])); + return (backend, config, item.Id, tracker, new(tracker)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Workflow_queue_and_send_back_share_policy_without_leaving_claims(bool useQueue) + { + var f = await WorkflowFixture(useQueue); + var before = await f.Tracker.GetOperationalAsync(f.Config, f.Id, default); + var queued = await f.Actions.ExecuteAsync(f.Config, f.Id, "queue", WorkflowActionService.Version(f.Config, before), default); + Assert.Equal("applied", queued.Outcome); + Assert.False(queued.StartsWorker); + Assert.Equal("Automation", queued.After.Status); + Assert.Equal(useQueue, queued.After.AutomaticExecutionAllowed); + Assert.Equal(ClaimOwnershipState.Unclaimed, (await f.Backend.GetClaimOwnershipAsync(f.Config, f.Id, default)).State); + var sentBack = await f.Actions.ExecuteAsync(f.Config, f.Id, "send-back", queued.StateVersion, default); + Assert.Equal("Ideas", sentBack.After.Status); + Assert.False(sentBack.After.AutomaticExecutionAllowed); + Assert.Equal(ClaimOwnershipState.Unclaimed, (await f.Backend.GetClaimOwnershipAsync(f.Config, f.Id, default)).State); + } + + [Fact] + public async Task Workflow_rejects_stale_content_and_configuration_without_mutation() + { + var f = await WorkflowFixture(); + var observed = await f.Tracker.GetOperationalAsync(f.Config, f.Id, default); + var version = WorkflowActionService.Version(f.Config, observed); + var claim = await f.Backend.TryClaimAsync(f.Config, f.Id, AgentExecutionContext.Human, default); + var handle = new ClaimHandle(AgentExecutionContext.Human, claim.ClaimToken); + await f.Backend.UpdateAsync(f.Config, f.Id, new(new(Title: OptionalValue.From("Changed"), + Body: default, Status: default, Priority: default), false, ClaimHandle: handle), default); + await f.Backend.ReleaseAsync(f.Config, f.Id, handle, false, DispatchStateOnRelease.Preserve, default); + var before = TrackerContents(); + var error = await Assert.ThrowsAsync(() => f.Actions.ExecuteAsync(f.Config, f.Id, "queue", version, default)); + Assert.Equal("ACTION_STATE_CHANGED", error.Code); + observed = await f.Tracker.GetOperationalAsync(f.Config, f.Id, default); + version = WorkflowActionService.Version(f.Config, observed); + error = await Assert.ThrowsAsync(() => f.Actions.ExecuteAsync(f.Config with { Worker = new() { UseWorkerQueue = false } }, + f.Id, "queue", version, default)); + Assert.Equal("ACTION_STATE_CHANGED", error.Code); + Assert.Equal(before, TrackerContents()); + } + + [Fact] + public async Task Workflow_rejects_claim_contention_wrong_action_and_invalid_transition() + { + var f = await WorkflowFixture(); + var claim = await f.Backend.TryClaimAsync(f.Config, f.Id, AgentExecutionContext.Human, default); + var before = TrackerContents(); + var error = await Assert.ThrowsAsync(() => f.Actions.ExecuteAsync(f.Config, f.Id, "queue", null, default)); + Assert.Equal("CLAIM_HELD", error.Code); + Assert.Equal(before, TrackerContents()); + Assert.Equal(claim.ClaimantId, (await f.Backend.GetClaimOwnershipAsync(f.Config, f.Id, default)).ClaimantId); + error = await Assert.ThrowsAsync(() => f.Actions.ExecuteAsync(f.Config, f.Id, "queue; echo unsafe", null, default)); + Assert.Equal("ACTION_EXECUTION_UNSUPPORTED", error.Code); + await f.Backend.ReleaseAsync(f.Config, f.Id, new(AgentExecutionContext.Human, claim.ClaimToken), false, DispatchStateOnRelease.Preserve, default); + error = await Assert.ThrowsAsync(() => f.Actions.ExecuteAsync(f.Config, f.Id, "send-back", null, default)); + Assert.Equal("WORKFLOW_STATE_INVALID", error.Code); + } + + [Fact] + public async Task Workflow_concurrent_queue_applies_once() + { + var f = await WorkflowFixture(); + var version = WorkflowActionService.Version(f.Config, await f.Tracker.GetOperationalAsync(f.Config, f.Id, default)); + async Task Attempt() + { + try { return (await f.Actions.ExecuteAsync(f.Config, f.Id, "queue", version, default)).Outcome; } + catch (TrackerException error) { return error.Code; } + } + var outcomes = await Task.WhenAll(Attempt(), Attempt()); + Assert.Single(outcomes, outcome => outcome == "applied"); + Assert.Single(outcomes, outcome => outcome == "WORKFLOW_STATE_INVALID"); + Assert.Equal(ClaimOwnershipState.Unclaimed, (await f.Backend.GetClaimOwnershipAsync(f.Config, f.Id, default)).State); + } + + [Fact] + public async Task Workflow_resume_preserves_session_and_fences_previous_claim() + { + var (backend, config, id, oldHandle) = await CreatePausedItemAsync(); + var tracker = new TrackerService(new TrackerBackendRegistry([backend])); + var before = await tracker.GetOperationalAsync(config, id, default); + var result = await new WorkflowActionService(tracker).ExecuteAsync(config, id, "resume", + WorkflowActionService.Version(config, before), default); + Assert.Equal(DispatchStates.Queued, result.After.DispatchState); + Assert.Equal(before.Item.Status, result.After.Status); + var after = await tracker.GetOperationalAsync(config, id, default); + Assert.Equal(before.Session!.SessionId, after.Session!.SessionId); + Assert.Equal(before.Session.WorkspacePath, after.Session.WorkspacePath); + Assert.Equal(before.Session.Context, after.Session.Context); + Assert.Equal(ClaimOwnershipState.Unclaimed, after.Claim.State); + await Assert.ThrowsAsync(() => backend.RenewClaimAsync(config, id, oldHandle, null, null, default)); + var error = await Assert.ThrowsAsync(() => new WorkflowActionService(tracker).ExecuteAsync(config, id, "resume", null, default)); + Assert.Equal("WORKER_ITEM_NOT_PAUSED", error.Code); + } +}