diff --git a/README.md b/README.md index 2c0acefb..f0b7752d 100644 --- a/README.md +++ b/README.md @@ -53,12 +53,20 @@ requirements and the exact coordination guarantees. With Local Markdown, `wrighty web` provides the board, item editor, recovery actions, and local worker visibility: -[![Local Markdown Wrighty board with Todo, Worker queue, In Progress, Done, and attention-required items](docs/assets/screenshots/local-markdown-web-ui-board.png)](docs/assets/screenshots/local-markdown-web-ui-board.png) +

+ Local Markdown Wrighty board with Todo, Worker queue, In Progress, and Done columns + Local Markdown Wrighty board with the Agents popover open + Local Markdown Wrighty board with the Workers popover open +

+

+ Local Markdown Wrighty board showing an item that needs attention + Local Markdown Wrighty Operations view +

With the GitHub backend, the configured GitHub Project remains the shared board. Wrighty adds the queue, policy, claim, and recovery state used by local workers on each computer: -[![GitHub Project board with Wrighty queue, policy, claim, and recovery fields](docs/assets/screenshots/github-board.png)](docs/assets/screenshots/github-board.png) +GitHub Project board with Wrighty queue, policy, claim, and recovery fields See [Wrighty workflows](docs/workflows.md) for the actions behind these views and the [Web console](docs/reference/web-console.md) for the backend-specific web surfaces. diff --git a/docs/assets/screenshots/local-markdown-web-ui-board-agents.png b/docs/assets/screenshots/local-markdown-web-ui-board-agents.png new file mode 100644 index 00000000..9d9be800 Binary files /dev/null and b/docs/assets/screenshots/local-markdown-web-ui-board-agents.png differ diff --git a/docs/assets/screenshots/local-markdown-web-ui-board-needs-attention.png b/docs/assets/screenshots/local-markdown-web-ui-board-needs-attention.png new file mode 100644 index 00000000..bd969ff1 Binary files /dev/null and b/docs/assets/screenshots/local-markdown-web-ui-board-needs-attention.png differ diff --git a/docs/assets/screenshots/local-markdown-web-ui-board-workers.png b/docs/assets/screenshots/local-markdown-web-ui-board-workers.png new file mode 100644 index 00000000..6028f3cb Binary files /dev/null and b/docs/assets/screenshots/local-markdown-web-ui-board-workers.png differ diff --git a/docs/assets/screenshots/local-markdown-web-ui-board.png b/docs/assets/screenshots/local-markdown-web-ui-board.png index 23ddd916..b67fb879 100644 Binary files a/docs/assets/screenshots/local-markdown-web-ui-board.png and b/docs/assets/screenshots/local-markdown-web-ui-board.png differ diff --git a/docs/assets/screenshots/local-markdown-web-ui-item-attention.png b/docs/assets/screenshots/local-markdown-web-ui-item-attention.png deleted file mode 100644 index 2ee4a302..00000000 Binary files a/docs/assets/screenshots/local-markdown-web-ui-item-attention.png and /dev/null differ diff --git a/docs/assets/screenshots/local-markdown-web-ui-item-attention2.png b/docs/assets/screenshots/local-markdown-web-ui-item-attention2.png deleted file mode 100644 index 1c5c0422..00000000 Binary files a/docs/assets/screenshots/local-markdown-web-ui-item-attention2.png and /dev/null differ diff --git a/docs/assets/screenshots/local-markdown-web-ui-item-create.png b/docs/assets/screenshots/local-markdown-web-ui-item-create.png deleted file mode 100644 index 51f201dd..00000000 Binary files a/docs/assets/screenshots/local-markdown-web-ui-item-create.png and /dev/null differ diff --git a/docs/assets/screenshots/local-markdown-web-ui-operations.png b/docs/assets/screenshots/local-markdown-web-ui-operations.png index e0ed1199..63ac6d69 100644 Binary files a/docs/assets/screenshots/local-markdown-web-ui-operations.png and b/docs/assets/screenshots/local-markdown-web-ui-operations.png differ diff --git a/docs/reference/execution-profiles.md b/docs/reference/execution-profiles.md index 6e7cfbfa..4b7634ef 100644 --- a/docs/reference/execution-profiles.md +++ b/docs/reference/execution-profiles.md @@ -103,8 +103,10 @@ wrighty edit --profile economy --claimant-id --claim-token wrighty edit --clear-profile --claimant-id --claim-token ``` -On the web console the item editor offers **Execution profile** beside **Agent policy**, -whenever the repository configures a vocabulary. +On the web console the create and edit item forms offer **Execution profile** beside **Agent policy**. +They show the repository vocabulary when configured, or the built-in `economy`, `balanced`, and +`deep` names otherwise. The repository-default option includes the configured default profile name +when one exists, or says `vendor defaults` when Wrighty will pass no model or effort override. **Resolution fails closed.** A profile that resolves to nothing usable is an error (`AGENT_PROFILE_UNAVAILABLE`), never a quiet fallback. Wrighty does not drop to a cheaper profile to diff --git a/docs/reference/web-console.md b/docs/reference/web-console.md index e2244ee6..cf25903d 100644 --- a/docs/reference/web-console.md +++ b/docs/reference/web-console.md @@ -47,10 +47,19 @@ not replace the board. ## Start, observe, and stop workers -**Operations → Local worker processes** shows both kinds of local worker: +The header's **Workers** button opens an installation-local anchored popover, matching the +**Agents** control. It lists running +workers, distinguishes **Worker preparing** from **Agent working**, shows the current item, agent, +runtime, and heartbeat, and offers a confirmed graceful **Stop** plus **Details**. **Details** opens +Operations at that exact worker and briefly highlights it. **Start new worker** adds a worker owned +by the current web-console process. Stale or unverifiable registrations stay out of the running +list and are linked to the complete Operations view. + +**Operations → Local worker processes** remains the complete surface and shows both kinds of local +worker: - **Hosted by this web console** is a background task owned by the current `wrighty web` process. - Every **Start worker** action adds another generic continuous worker using the configuration + Every **Start worker** or **Start new worker** action adds another generic continuous worker using the configuration snapshot loaded at web startup. Closing, refreshing, or navigating away from the browser does not stop them. Stopping the `wrighty web` process does. - **Started outside the web console** is a `wrighty worker` process launched by a terminal, service @@ -61,8 +70,9 @@ and current agent. Older worker versions that did not publish the agent say so i from the item or claim. Stale and unverifiable records label item, agent, and state as last-reported facts. The header's **Workers** button reports the verified running count from every tab, labels an idle pool, and highlights how many workers are actively processing an item. Stale or unverifiable -registrations are called out separately. Select the button to open and focus **Operations → Local -worker processes**. The current hosted worker card exposes a bounded structured operational log. Opening it +registrations are called out separately. Select the button to open the running-worker overview; +use **Details** or **View all in Operations** for the complete process controls. The current hosted +worker card exposes a bounded structured operational log. Opening it starts at the newest event. Normal and manual Operations refreshes continue updating the whole worker card while preserving the disclosure and its scroll position for the same run. Updates follow the tail until the operator scrolls back, at which point Wrighty preserves that reading @@ -116,6 +126,13 @@ Operations organizes a bounded window. Wrighty asks the backend for one item bey and the visible count describe the loaded 100-item window, not the entire repository. Refine the filters or use the backend's native tracker when repository-wide discovery is required. +Board cards distinguish a worker that has claimed an item from one that has started its agent. A +neutral **Worker preparing** card means the worker owns the claim while it prepares the workspace +and validates the launch. After the operating-system process starts, the card changes to a raised, +outlined **Live — Agent working** treatment with a full-width execution banner, using the selected +agent's display name. Its motion becomes static when the browser requests reduced motion. Older +claims that do not record an execution phase continue to display as working for compatibility. + ## Open a retained session from Operations When an operational item needs attention, or is Done with no active claim, and this installation @@ -327,22 +344,28 @@ active-work, completion, and archive-triggering statuses are excluded and reject With worker-queue authorization enabled, status owns execution eligibility: creation in `defaultPickFrom` authorizes execution and the form shows that rule instead of an independent checkbox. With queue authorization disabled, the form offers **Allow automatic execution**, off by -default. An agent policy does not imply eligibility. **Create item** uses the ordinary retry-safe -creation pipeline. It never claims the new item, starts a worker, or launches a vendor agent. +default. The form also offers the item's agent and execution-profile policies; neither implies +eligibility. **Create item** uses the ordinary retry-safe creation pipeline. It never claims the new +item, starts a worker, or launches a vendor agent. The item editor's **Execution policy** section explains status-controlled authorization when the worker queue owns that decision; otherwise it offers the per-item automatic-execution checkbox. It -also carries agent policy and — when the repository configures an execution-profile vocabulary — -**Execution profile**. A repository that does not use profiles sees no such control. The choice -applies to the item's next fresh run; a recorded session keeps the model and effort it started with. See -[Execution profiles](execution-profiles.md). +also carries **Agent policy** and **Execution profile**. The profile choices come from the repository +vocabulary when configured, or from the built-in `economy`, `balanced`, and `deep` names otherwise. +For both policies, the repository-default choice includes the configured value when one exists; an +execution profile with no repository default says **vendor defaults**, meaning Wrighty passes no +model or effort override. The item viewer reports both policies with the same repository-default +labels. A profile choice applies to the item's next fresh run; a recorded session keeps the model and +effort it started with. See [Execution profiles](execution-profiles.md). The web console also shows configured status columns, priority and claim state, supports active/archived filtering, and renders each item's Markdown. The Board-wide sort offers operational priority, item number, creation/update time, configured priority rank, and title. A compact control in each status column can override that default; choose **Board sort** there to clear the override. Every explicit order uses item number as its stable tie-break, and missing values remain last in -both directions. +both directions. The default operational order keeps scarce live work visible above a potentially +large backlog: **Agent working**, **Worker preparing**, **Needs attention**, retry scheduled, +handoff queued, resume queued, then other items. Operations uses the same default order. Structured Board filters narrow claimant kind, associated agent, priority, claim ownership, and update recency. The associated agent is the active claim's agent when present, then the retained diff --git a/docs/reference/worker.md b/docs/reference/worker.md index 081e41fe..d0d2bfdb 100644 --- a/docs/reference/worker.md +++ b/docs/reference/worker.md @@ -1125,7 +1125,9 @@ wrighty status --json # same groups for scripting - **Completed — retained worktree** — finished items whose worktree is still present, each with the branch, its `dirty`/`merged` git state, and the integration commands for the configured policy. - **Paused — resumable session** — retained sessions waiting to be resumed, with the resume command. -- **Active** — items with a live claim (agent processing, human editing, automation). +- **Active** — items with a live claim. A worker-run claim reports **Worker preparing** while the + workspace and launch are being prepared, then the selected agent as **working** after its + operating-system process starts; human editing and automation claims retain their own labels. - **Resume queued** — items marked to be resumed by a continuous worker. - **Retry scheduled** — retained sessions waiting for their bounded retry time. - **Handoff queued** — retained workspaces waiting for a due cross-agent continuation by the @@ -1135,7 +1137,7 @@ wrighty status --json # same groups for scripting `wrighty provider probe AGENT` to test it immediately without selecting a work item. - **Local worker processes** — one installation-local heartbeat record per worker invocation, including CLI-process/web-hosted origin, PID, verified/stale/unknown liveness, lifecycle state, - current item and agent, cooperative-control version, startup configuration revision, and a + current item title and agent, cooperative-control version, startup configuration revision, and a sanitized invocation summary. The web console orders Running, then Unknown, then Stale workers, then uses immutable start time and run ID within each group so heartbeat updates never make cards trade places. Stale rows are visually de-emphasized. diff --git a/docs/workflows.md b/docs/workflows.md index 55ddd9ed..f7d4a8f6 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -172,9 +172,7 @@ For Local Markdown, run `wrighty web`, choose **New item**, enter the structured **Create item**. Creation does not claim the item or start a worker. The resulting card is selected and the board refreshes. -[![Local Markdown New item form with status, priority, automatic execution, and agent policy controls](assets/screenshots/local-markdown-web-ui-item-create.png)](assets/screenshots/local-markdown-web-ui-item-create.png) - -The example above creates an explicitly authorized **Worker queue** item. For ordinary intake, +To create an explicitly authorized **Worker queue** item, select that status. For ordinary intake, choose **Todo** and leave **Allow automatic execution** unchecked. For GitHub, create from the configured Project's `Todo` group or column in a board grouped by the @@ -383,8 +381,8 @@ worktrees, or choose `--workspace-mode shared` explicitly and accept the collisi ### Web console -Use **Start worker** on Operations to add a continuous worker hosted by the current web server. -Select it again to add more. Closing or navigating away from the browser does not stop them; +Use **Start new worker** in the header's **Workers** overview—or **Start worker** on Operations—to +add a continuous worker hosted by the current web server. Select it again to add more. Closing or navigating away from the browser does not stop them; stopping `wrighty web` does. Workspace concurrency behaves exactly as it does for CLI workers: `current` serializes access and shows extra workers waiting, `worktree` isolates concurrent work, and `shared` accepts collision risk. For a persistent service, start `wrighty worker` independently @@ -498,12 +496,7 @@ Wrighty takes no claim, passes no claimant credentials, and leaves any further c workspace changes to the operator. The Local Markdown item panel keeps the agent's request, last-run result, retained session and -workspace, and the next recovery actions together. Select either view for the full-size image. - -

- Upper part of a Local Markdown item panel showing an agent request for clarification and the last run result - Lower part of the same item panel showing retained claim, session, workspace, and recovery actions -

+workspace, and the next recovery actions together. 1. Open the item marked **Agent needs attention**. 2. If the work item is already correct and an external problem has been fixed, choose **Queue for diff --git a/src/Highbyte.Wrighty.Cli/Output/OutputWriter.cs b/src/Highbyte.Wrighty.Cli/Output/OutputWriter.cs index 2296c0da..87d29968 100644 --- a/src/Highbyte.Wrighty.Cli/Output/OutputWriter.cs +++ b/src/Highbyte.Wrighty.Cli/Output/OutputWriter.cs @@ -27,6 +27,7 @@ public sealed class OutputWriter( TextWriter error, Func? clock = null) { + private const string AgentFallback = "agent"; private readonly Func now = clock ?? (() => DateTimeOffset.UtcNow); private static readonly string[] PartialErrorDetailKeys = @@ -148,7 +149,8 @@ public async Task WriteStatusAsync( var completed = Group(items, OperationalStatuses.Completed); var paused = Group(items, OperationalStatuses.PausedSession); var active = items.Where(value => value.OperationalStatus - is OperationalStatuses.AgentActive + is OperationalStatuses.WorkerPreparing + or OperationalStatuses.AgentActive or OperationalStatuses.HumanEditing or OperationalStatuses.AutomationActive).ToArray(); var queued = Group(items, OperationalStatuses.Queued); @@ -1730,16 +1732,18 @@ private static string WorktreeMarker(WorkItemOperationalState value) => private string OperationalStatusToken(WorkItemOperationalState value) => value.OperationalStatus switch { OperationalStatuses.NeedsAttention => "!attention", + OperationalStatuses.WorkerPreparing => + $"preparing:{value.Claim.Agent ?? AgentFallback}", OperationalStatuses.AgentActive when IsWorkerRunClaim(value) => - $"processing:{value.Claim.Agent ?? "agent"}", - OperationalStatuses.AgentActive => $"claimed:{value.Claim.Agent ?? "agent"}", - OperationalStatuses.Queued => $"queued:{value.Session?.Agent ?? "agent"}", + $"processing:{value.Claim.Agent ?? AgentFallback}", + OperationalStatuses.AgentActive => $"claimed:{value.Claim.Agent ?? AgentFallback}", + OperationalStatuses.Queued => $"queued:{value.Session?.Agent ?? AgentFallback}", OperationalStatuses.RetryScheduled => value.Session?.Dispatch is { } dispatch ? $"retry:{dispatch.NotBefore.ToLocalTime():HH:mm}" : "retry", OperationalStatuses.HandoffQueued => - $"handoff:{value.Session?.Dispatch?.Agent ?? "agent"}", - OperationalStatuses.PausedSession => $"paused:{value.Session?.Agent ?? "agent"}", + $"handoff:{value.Session?.Dispatch?.Agent ?? AgentFallback}", + OperationalStatuses.PausedSession => $"paused:{value.Session?.Agent ?? AgentFallback}", OperationalStatuses.Completed => "completed", OperationalStatuses.HumanEditing => "human", OperationalStatuses.AutomationActive => "automation", @@ -1750,6 +1754,7 @@ OperationalStatuses.AgentActive when IsWorkerRunClaim(value) => private string OperationalStatusLabel(WorkItemOperationalState value) => value.OperationalStatus switch { OperationalStatuses.NeedsAttention => "Needs attention", + OperationalStatuses.WorkerPreparing => "Worker preparing", OperationalStatuses.AgentActive when IsWorkerRunClaim(value) => $"{AgentLabel(value.Claim.Agent) ?? "Agent"} processing", OperationalStatuses.AgentActive => $"{AgentLabel(value.Claim.Agent) ?? "Agent"} claimed", @@ -1759,7 +1764,7 @@ OperationalStatuses.AgentActive when IsWorkerRunClaim(value) => : "Retry scheduled", OperationalStatuses.HandoffQueued => value.Session?.Dispatch is { } dispatch ? $"{AgentLabel(dispatch.SessionAgent) ?? "Agent"} → " + - $"{AgentLabel(dispatch.Agent) ?? "agent"}" + $"{AgentLabel(dispatch.Agent) ?? AgentFallback}" : "Handoff queued", OperationalStatuses.PausedSession => "Session retained", OperationalStatuses.Completed => "Completed", @@ -1892,6 +1897,7 @@ private object OperationalDto( claimantId = claimView?.ClaimantId, sessionId = claimView?.SessionId, workspacePath = claimView?.WorkspacePath, + executionPhase = claimView?.ExecutionPhase, workerRun = IsWorkerRunClaim(value), leaseRemainingSeconds = LeaseRemainingSeconds(value.Claim), value.Claim.TakeoverAvailable diff --git a/src/Highbyte.Wrighty.Core/AgentContext/AgentExecutionContext.cs b/src/Highbyte.Wrighty.Core/AgentContext/AgentExecutionContext.cs index a9e02f51..d5a48f70 100644 --- a/src/Highbyte.Wrighty.Core/AgentContext/AgentExecutionContext.cs +++ b/src/Highbyte.Wrighty.Core/AgentContext/AgentExecutionContext.cs @@ -7,7 +7,8 @@ public sealed record AgentExecutionContext( string? Warning = null, ClaimantKind ClaimantKind = ClaimantKind.Unknown, string? ClaimantId = null, - string? ClaimToken = null) + string? ClaimToken = null, + string? ExecutionPhase = null) { public ClaimantKind EffectiveClaimantKind => ClaimantKind; diff --git a/src/Highbyte.Wrighty.Core/Claims/ClaimMarker.cs b/src/Highbyte.Wrighty.Core/Claims/ClaimMarker.cs index db2b9774..1150e9ea 100644 --- a/src/Highbyte.Wrighty.Core/Claims/ClaimMarker.cs +++ b/src/Highbyte.Wrighty.Core/Claims/ClaimMarker.cs @@ -49,7 +49,10 @@ value.EventType is not ("acquired" or "takenOver" or "released" or Agent = Normalize(value.Agent), SessionId = NormalizeOpaque(value.SessionId), WorkspacePath = NormalizeWorkspace(value.WorkspacePath), - ClaimantKind = ClaimantKinds.ToStorageValue(ClaimantKinds.FromStorageValue(value.ClaimantKind)) + ClaimantKind = ClaimantKinds.ToStorageValue(ClaimantKinds.FromStorageValue(value.ClaimantKind)), + ExecutionPhase = ClaimantKinds.FromStorageValue(value.ClaimantKind) == ClaimantKind.Agent + ? ClaimExecutionPhases.Normalize(value.ExecutionPhase) + : null }; return true; } diff --git a/src/Highbyte.Wrighty.Core/Claims/ClaimModels.cs b/src/Highbyte.Wrighty.Core/Claims/ClaimModels.cs index 06d22cd9..e9586dcd 100644 --- a/src/Highbyte.Wrighty.Core/Claims/ClaimModels.cs +++ b/src/Highbyte.Wrighty.Core/Claims/ClaimModels.cs @@ -3,6 +3,19 @@ namespace Highbyte.Wrighty.Claims; +public static class ClaimExecutionPhases +{ + public const string Preparing = "preparing"; + public const string Invoking = "invoking"; + + public static string? Normalize(string? value) => value?.Trim().ToLowerInvariant() switch + { + Preparing => Preparing, + Invoking => Invoking, + _ => null + }; +} + [method: System.Text.Json.Serialization.JsonConstructor] public sealed record ClaimRecord( int Version, @@ -17,7 +30,8 @@ public sealed record ClaimRecord( string? Agent = null, string? SessionId = null, string ClaimantKind = "unknown", - string? WorkspacePath = null); + string? WorkspacePath = null, + string? ExecutionPhase = null); public sealed record ClaimEvent( long CommentId, @@ -44,7 +58,8 @@ public sealed record ClaimResult( string? ClaimantId = null, string? ClaimToken = null, bool TakeoverAvailable = false, - string? WorkspacePath = null); + string? WorkspacePath = null, + string? ExecutionPhase = null); public enum ClaimOwnershipState { @@ -62,7 +77,8 @@ public sealed record ClaimOwnershipResult( string? SessionId = null, string ClaimantKind = "unknown", bool TakeoverAvailable = false, - string? WorkspacePath = null); + string? WorkspacePath = null, + string? ExecutionPhase = null); /// /// The durable outcome of the most recent agent run recorded for a work item. Captured when the diff --git a/src/Highbyte.Wrighty.Core/Claims/GitHubClaimService.cs b/src/Highbyte.Wrighty.Core/Claims/GitHubClaimService.cs index f405a31e..9ac71767 100644 --- a/src/Highbyte.Wrighty.Core/Claims/GitHubClaimService.cs +++ b/src/Highbyte.Wrighty.Core/Claims/GitHubClaimService.cs @@ -298,7 +298,11 @@ public async Task TakeoverAsync(TrackerConfig config, WorkItemId id { Agent = claimantContext.Agent ?? current.Claim.Agent, SessionId = claimantContext.SessionId ?? current.Claim.SessionId, - WorkspacePath = current.Claim.WorkspacePath + WorkspacePath = current.Claim.WorkspacePath, + ExecutionPhase = claimantContext.EffectiveClaimantKind == ClaimantKind.Agent + ? ClaimExecutionPhases.Normalize(claimantContext.ExecutionPhase) ?? + current.Claim.ExecutionPhase + : null }; await CreateAsync(config, issue, claim, cancellationToken); var winner = await ResolvedAsync(config, issue, id, cancellationToken); @@ -349,7 +353,11 @@ public async Task RenewAsync( Agent = claimHandle.Claimant.Agent ?? current.Claim.Agent, SessionId = sessionId ?? current.Claim.SessionId, ClaimantKind = ClaimantKinds.ToStorageValue(claimHandle.Claimant.EffectiveClaimantKind), - WorkspacePath = workspacePath ?? current.Claim.WorkspacePath + WorkspacePath = workspacePath ?? current.Claim.WorkspacePath, + ExecutionPhase = claimHandle.Claimant.EffectiveClaimantKind == ClaimantKind.Agent + ? ClaimExecutionPhases.Normalize(claimHandle.Claimant.ExecutionPhase) ?? + current.Claim.ExecutionPhase + : null }; await CreateAsync(config, issue, renewed, cancellationToken); var winner = await ResolvedAsync(config, issue, id, cancellationToken); @@ -528,7 +536,7 @@ private static ClaimOwnershipResult Ownership( local ? ClaimOwnershipState.OwnedByCurrent : ClaimOwnershipState.HeldByOther, claim.InstallationId, claim.ExpiresAt, claim.ClaimantId, claim.Agent, claim.SessionId, claim.ClaimantKind, local, - CachedWorkspace(claim, worker, cached)); + CachedWorkspace(claim, worker, cached), claim.ExecutionPhase); } /// @@ -838,7 +846,10 @@ private static ClaimRecord NewEvent(string type, string worker, string claimantI AgentExecutionContext context, DateTimeOffset now, TrackerConfig config, string? previous) => new(3, Guid.NewGuid().ToString("N"), worker, now, now.AddMinutes(config.LeaseMinutes), type, claimantId, Guid.NewGuid().ToString("N"), previous, context.Agent, context.SessionId, - ClaimantKinds.ToStorageValue(context.EffectiveClaimantKind)); + ClaimantKinds.ToStorageValue(context.EffectiveClaimantKind), + ExecutionPhase: context.EffectiveClaimantKind == ClaimantKind.Agent + ? ClaimExecutionPhases.Normalize(context.ExecutionPhase) + : null); private static ClaimResult Result(ClaimRecord claim, ClaimOutcome outcome, bool takeover) => new(outcome, claim.InstallationId, claim.ExpiresAt, claim.EventId, @@ -847,7 +858,8 @@ outcome is ClaimOutcome.Acquired or ClaimOutcome.AlreadyOwned or ClaimOutcome.Ta ? claim.ClaimToken : null, takeover, - claim.WorkspacePath); + claim.WorkspacePath, + claim.ExecutionPhase); private static string ResolveClaimantId(AgentExecutionContext context, bool generate) { diff --git a/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalMarkdownTrackerBackend.cs b/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalMarkdownTrackerBackend.cs index ddae2ce5..4a0ba196 100644 --- a/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalMarkdownTrackerBackend.cs +++ b/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalMarkdownTrackerBackend.cs @@ -1360,7 +1360,10 @@ public async Task TryClaimAsync( agentContext.SessionId, now, now.AddMinutes(config.LeaseMinutes), - ClaimantKinds.ToStorageValue(agentContext.EffectiveClaimantKind)); + ClaimantKinds.ToStorageValue(agentContext.EffectiveClaimantKind), + ExecutionPhase: agentContext.EffectiveClaimantKind == ClaimantKind.Agent + ? ClaimExecutionPhases.Normalize(agentContext.ExecutionPhase) + : null); state.Claims[document.Id] = claim; await LocalRuntimeStateStore.SaveUnlockedAsync(paths.Root, state, cancellationToken); return ClaimResult(claim, ClaimOutcome.Acquired, true); @@ -1403,7 +1406,12 @@ public async Task TakeoverAsync( claimantContext.Agent ?? current.Agent, claimantContext.SessionId ?? current.SessionId, now, now.AddMinutes(config.LeaseMinutes), ClaimantKinds.ToStorageValue(claimantContext.EffectiveClaimantKind), - current.WorkspacePath); + current.WorkspacePath, + current.Branch, + claimantContext.EffectiveClaimantKind == ClaimantKind.Agent + ? ClaimExecutionPhases.Normalize(claimantContext.ExecutionPhase) ?? + current.ExecutionPhase + : null); state.Claims[document.Id] = replacement; await LocalRuntimeStateStore.SaveUnlockedAsync(paths.Root, state, cancellationToken); return ClaimResult(replacement, ClaimOutcome.TakenOver, true); @@ -1445,7 +1453,11 @@ public async Task RenewClaimAsync( SessionId = sessionId ?? current.SessionId, ClaimantKind = ClaimantKinds.ToStorageValue(claimHandle.Claimant.EffectiveClaimantKind), WorkspacePath = workspacePath ?? current.WorkspacePath, - Branch = branch ?? current.Branch + Branch = branch ?? current.Branch, + ExecutionPhase = claimHandle.Claimant.EffectiveClaimantKind == ClaimantKind.Agent + ? ClaimExecutionPhases.Normalize(claimHandle.Claimant.ExecutionPhase) ?? + current.ExecutionPhase + : null }; state.Claims[document.Id] = renewed; state.PreserveSession(document.Id, renewed, now); @@ -1481,7 +1493,8 @@ public async Task GetClaimOwnershipAsync( current.SessionId, current.ClaimantKind, string.Equals(current.InstallationId, worker, StringComparison.Ordinal), - current.WorkspacePath); + current.WorkspacePath, + current.ExecutionPhase); } public async Task GetAgentSessionAsync( @@ -2145,7 +2158,8 @@ outcome is ClaimOutcome.Acquired or ClaimOutcome.AlreadyOwned or ClaimOutcome.Ta ? claim.ClaimToken : null, takeoverAvailable, - claim.WorkspacePath); + claim.WorkspacePath, + claim.ExecutionPhase); private static string ResolveClaimantId(AgentExecutionContext context, bool generateForAgent) { @@ -2252,7 +2266,8 @@ private static WorkItemClaimSummary ClaimSummary( claim.ClaimantKind, claim.ClaimantId, string.Equals(claim.InstallationId, worker, StringComparison.Ordinal), - claim.WorkspacePath); + claim.WorkspacePath, + claim.ExecutionPhase); } if (claim is not null) @@ -2266,7 +2281,8 @@ private static WorkItemClaimSummary ClaimSummary( claim.ClaimantKind, claim.ClaimantId, false, - claim.WorkspacePath); + claim.WorkspacePath, + claim.ExecutionPhase); } if (state.Runtime(documentId) is { } record) diff --git a/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalRuntimeStateStore.cs b/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalRuntimeStateStore.cs index ad48b0dc..bfd66949 100644 --- a/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalRuntimeStateStore.cs +++ b/src/Highbyte.Wrighty.Core/LocalMarkdown/LocalRuntimeStateStore.cs @@ -15,7 +15,8 @@ internal sealed record LocalClaimRecord( DateTimeOffset ExpiresAt, string ClaimantKind, string? WorkspacePath = null, - string? Branch = null) + string? Branch = null, + string? ExecutionPhase = null) { [System.Text.Json.Serialization.JsonIgnore] public bool HasAddress => diff --git a/src/Highbyte.Wrighty.Core/Models/WorkItemModels.cs b/src/Highbyte.Wrighty.Core/Models/WorkItemModels.cs index f79aa584..c61a49f7 100644 --- a/src/Highbyte.Wrighty.Core/Models/WorkItemModels.cs +++ b/src/Highbyte.Wrighty.Core/Models/WorkItemModels.cs @@ -200,7 +200,8 @@ public sealed record WorkItemClaimSummary( string ClaimantKind = "unknown", string? ClaimantId = null, bool TakeoverAvailable = false, - string? WorkspacePath = null) + string? WorkspacePath = null, + string? ExecutionPhase = null) { public static WorkItemClaimSummary FromOwnership(ClaimOwnershipResult ownership) => new( ownership.State, @@ -211,7 +212,8 @@ public sealed record WorkItemClaimSummary( ownership.ClaimantKind, ownership.ClaimantId, ownership.TakeoverAvailable, - ownership.WorkspacePath); + ownership.WorkspacePath, + ownership.ExecutionPhase); } public sealed record DashboardWorkItem( @@ -387,6 +389,7 @@ public static class OperationalStatuses public const string Queued = "queued"; public const string RetryScheduled = "retry-scheduled"; public const string HandoffQueued = "handoff-queued"; + public const string WorkerPreparing = "worker-preparing"; public const string AgentActive = "agent-active"; public const string HumanEditing = "human-editing"; public const string AutomationActive = "automation-active"; @@ -438,7 +441,11 @@ public static string Resolve( if (claim.State != ClaimOwnershipState.Unclaimed) { - return ClaimantKinds.FromStorageValue(claim.ClaimantKind) switch + var claimantKind = ClaimantKinds.FromStorageValue(claim.ClaimantKind); + if (claimantKind == ClaimantKind.Agent && + claim.ExecutionPhase == ClaimExecutionPhases.Preparing) + return WorkerPreparing; + return claimantKind switch { ClaimantKind.Agent => AgentActive, ClaimantKind.Human => HumanEditing, diff --git a/src/Highbyte.Wrighty.Core/Workers/AgentProcessRunner.cs b/src/Highbyte.Wrighty.Core/Workers/AgentProcessRunner.cs index 97c01138..8a420c48 100644 --- a/src/Highbyte.Wrighty.Core/Workers/AgentProcessRunner.cs +++ b/src/Highbyte.Wrighty.Core/Workers/AgentProcessRunner.cs @@ -5,6 +5,10 @@ namespace Highbyte.Wrighty.Workers; +public sealed record AgentProcessCallbacks( + Func? ProcessStarted = null, + Func? SessionStarted = null); + public interface IAgentProcessRunner { Task RunAsync( @@ -15,23 +19,59 @@ Task RunAsync( Func? sessionStarted, bool killOnCancellation, CancellationToken cancellationToken); + + Task RunWithCallbacksAsync( + AgentInvocation invocation, + IAgentAdapter adapter, + TimeSpan timeout, + IReadOnlyDictionary grantEnvironment, + AgentProcessCallbacks callbacks, + bool killOnCancellation, + CancellationToken cancellationToken) => + RunAsync(invocation, adapter, timeout, grantEnvironment, callbacks.SessionStarted, + killOnCancellation, cancellationToken); } public sealed class AgentProcessRunner(IExecutableResolver executables) : IAgentProcessRunner { - public async Task RunAsync( + public Task RunAsync( AgentInvocation invocation, IAgentAdapter adapter, TimeSpan timeout, IReadOnlyDictionary grantEnvironment, Func? sessionStarted, bool killOnCancellation, + CancellationToken cancellationToken) => + RunWithCallbacksAsync(invocation, adapter, timeout, grantEnvironment, + new AgentProcessCallbacks(SessionStarted: sessionStarted), + killOnCancellation, cancellationToken); + + public async Task RunWithCallbacksAsync( + AgentInvocation invocation, + IAgentAdapter adapter, + TimeSpan timeout, + IReadOnlyDictionary grantEnvironment, + AgentProcessCallbacks callbacks, + bool killOnCancellation, CancellationToken cancellationToken) { if (timeout <= TimeSpan.Zero) throw new TrackerException("ARGUMENT_INVALID", "--item-timeout must be positive.", 2); using var process = StartProcess(invocation, grantEnvironment); + if (callbacks.ProcessStarted is not null) + { + try + { + await callbacks.ProcessStarted(cancellationToken); + } + catch + { + Kill(process); + await process.WaitForExitAsync(CancellationToken.None); + throw; + } + } // The prompt goes here when there is one: an approved context on the command line would be // readable by every process on the machine, and would appear in the argument list worker @@ -54,7 +94,7 @@ public async Task RunAsync( }; var capture = new SessionCapture(); var stdoutTask = CaptureStdoutAsync( - process, writer, adapter, capture, sessionStarted, cancellationToken); + process, writer, adapter, capture, callbacks.SessionStarted, cancellationToken); using var timeoutCts = new CancellationTokenSource(timeout); using var combined = CancellationTokenSource.CreateLinkedTokenSource( diff --git a/src/Highbyte.Wrighty.Core/Workers/WorkerInstances.cs b/src/Highbyte.Wrighty.Core/Workers/WorkerInstances.cs index 1d0d467c..47111059 100644 --- a/src/Highbyte.Wrighty.Core/Workers/WorkerInstances.cs +++ b/src/Highbyte.Wrighty.Core/Workers/WorkerInstances.cs @@ -14,7 +14,8 @@ public enum WorkerInstanceState Stopping, Draining, StoppingNow, - Finalizing + Finalizing, + PreparingItem } public enum WorkerHostKind @@ -51,7 +52,8 @@ public sealed record WorkerInstance( WorkerHostKind HostKind = WorkerHostKind.CliProcess, string? CurrentAgent = null, int? ControlProtocolVersion = null, - IReadOnlyList? SupportedStopModes = null); + IReadOnlyList? SupportedStopModes = null, + string? CurrentItemTitle = null); public sealed record WorkerInstanceStatus( WorkerInstance Instance, @@ -93,6 +95,14 @@ Task UpdateAsync( CancellationToken cancellationToken) => UpdateAsync(currentItemId, state, cancellationToken); + Task UpdateAsync( + string? currentItemId, + string? currentItemTitle, + string? currentAgent, + WorkerInstanceState state, + CancellationToken cancellationToken) => + UpdateAsync(currentItemId, currentAgent, state, cancellationToken); + Task ReadStopRequestAsync(CancellationToken cancellationToken) => Task.FromResult(null); @@ -604,15 +614,36 @@ public async Task UpdateAsync( string? currentAgent, WorkerInstanceState state, CancellationToken cancellationToken) + => await UpdateAsync( + currentItemId, + currentItemTitle: null, + currentAgent, + state, + cancellationToken); + + public async Task UpdateAsync( + string? currentItemId, + string? currentItemTitle, + string? currentAgent, + WorkerInstanceState state, + CancellationToken cancellationToken) { await gate.WaitAsync(cancellationToken); try { if (disposed) return; + string? nextItemTitle = null; + if (currentItemId is not null) + { + nextItemTitle = currentItemTitle; + if (string.Equals(current.CurrentItemId, currentItemId, StringComparison.Ordinal)) + nextItemTitle ??= current.CurrentItemTitle; + } current = current with { CurrentItemId = currentItemId, + CurrentItemTitle = nextItemTitle, CurrentAgent = currentAgent, State = state, LastHeartbeatAt = clock() diff --git a/src/Highbyte.Wrighty.Core/Workers/WorkerModels.cs b/src/Highbyte.Wrighty.Core/Workers/WorkerModels.cs index e0ab7aa1..d9781a18 100644 --- a/src/Highbyte.Wrighty.Core/Workers/WorkerModels.cs +++ b/src/Highbyte.Wrighty.Core/Workers/WorkerModels.cs @@ -97,7 +97,10 @@ public sealed record WorkerEvent( ProviderCapacity? ProviderCapacity = null, // The effective spawned-agent permission posture for this run, so a machine consumer can see // what the agent was actually granted rather than inferring it from the vendor argv. - AgentPermissions? Permissions = null); + AgentPermissions? Permissions = null, + // Local worker-process presentation only. It lets the web console identify the active item + // without polling the tracker while its worker drawer is open. + string? ItemTitle = null); public enum WorkerEventSemantic { @@ -114,7 +117,7 @@ public static class WorkerEventClassifier { "check" or "finished" or "workspace-removed" or "requirements-assessment-ready" => WorkerEventSemantic.Success, - "info" or "ready" or "started" or "resumed" or "session" or "dry-run" or + "info" or "ready" or "preparing" or "started" or "resumed" or "session" or "dry-run" or "retry-due" or "retry-started" or "handoff-due" or "handoff-directed" or "handoff-started" or "provider-probe-started" or "provider-available" or diff --git a/src/Highbyte.Wrighty.Core/Workers/WorkerRunHost.cs b/src/Highbyte.Wrighty.Core/Workers/WorkerRunHost.cs index 193157a4..d297c3c8 100644 --- a/src/Highbyte.Wrighty.Core/Workers/WorkerRunHost.cs +++ b/src/Highbyte.Wrighty.Core/Workers/WorkerRunHost.cs @@ -29,6 +29,7 @@ public sealed record WorkerRunCallbacks( internal sealed record WorkerInstanceEventState( string? ItemId, + string? ItemTitle, string? Agent, WorkerInstanceState State); @@ -38,17 +39,23 @@ internal static class WorkerInstanceEventProjection WorkerEvent value, WorkerRunControl control) { - var running = value.Type is "started" or "resumed" or "running" or "session"; + var preparing = value.Type == "preparing"; + var running = value.Type is "started" or "resumed" or "retry-started" or + "handoff-started" or "requirements-assessment-started" or "running" or "session"; var terminal = value.Type is "finished" or "needs-attention" or "failed" or "fenced" or "timed-out" or "rejected" or "retry-scheduled" or "interrupted"; - if (running && value.ItemId is not null) + if ((preparing || running) && value.ItemId is not null) { + var state = WorkerInstanceState.RunningItem; + if (control.IntakeClosed) + state = WorkerInstanceState.Draining; + else if (preparing) + state = WorkerInstanceState.PreparingItem; return new WorkerInstanceEventState( value.ItemId, + value.ItemTitle, value.Agent, - control.IntakeClosed - ? WorkerInstanceState.Draining - : WorkerInstanceState.RunningItem); + state); } if (!terminal) return null; @@ -58,7 +65,7 @@ internal static class WorkerInstanceEventProjection terminalState = WorkerInstanceState.Finalizing; else if (control.IntakeClosed) terminalState = WorkerInstanceState.Draining; - return new WorkerInstanceEventState(null, null, terminalState); + return new WorkerInstanceEventState(null, null, null, terminalState); } } @@ -284,10 +291,10 @@ await TryUpdateAsync( registration, null, null, + null, WorkerInstanceState.Finalizing, warningState, - callbacks.Warn, - CancellationToken.None); + callbacks.Warn); } await pollingStop.CancelAsync(); try @@ -394,11 +401,11 @@ private static async Task ProjectEventAsync( await TryUpdateAsync( registration, projected.ItemId, + projected.ItemTitle, projected.Agent, projected.State, warningState, - warn, - CancellationToken.None); + warn); } await emit(value); } @@ -406,15 +413,20 @@ await TryUpdateAsync( private static async Task TryUpdateAsync( IWorkerInstanceRegistration registration, string? itemId, + string? itemTitle, string? agent, WorkerInstanceState state, RegistryWarningState warningState, - Func? warn, - CancellationToken cancellationToken) + Func? warn) { try { - await registration.UpdateAsync(itemId, agent, state, cancellationToken); + await registration.UpdateAsync( + itemId, + itemTitle, + agent, + state, + CancellationToken.None); } catch (Exception exception) when ( exception is IOException or UnauthorizedAccessException) diff --git a/src/Highbyte.Wrighty.Core/Workers/WorkerService.cs b/src/Highbyte.Wrighty.Core/Workers/WorkerService.cs index 4fec7bb9..11a5fb13 100644 --- a/src/Highbyte.Wrighty.Core/Workers/WorkerService.cs +++ b/src/Highbyte.Wrighty.Core/Workers/WorkerService.cs @@ -45,6 +45,7 @@ public sealed class WorkerService( // The lifecycle event name, distinct from the WorkerDispatchStates value of the same text: one // is the emitted event, the other the published item state. private const string NeedsAttentionEvent = "needs-attention"; + private const string PreparingEvent = "preparing"; /// Emitted whenever a claim turned out to be stale, expired, or owned elsewhere. private const string FencedEvent = "fenced"; @@ -629,7 +630,8 @@ private async Task RunFreshCandidateAsync( null, AgentContextSource.ExplicitOption, ClaimantKind: kind, - ClaimantId: claimantId); + ClaimantId: claimantId, + ExecutionPhase: ClaimExecutionPhases.Preparing); var agentSettings = await LoadUserSettingsAsync(cancellationToken); var providerStates = await ProviderStatesAsync(config, cancellationToken); await using var workspaceLease = options.WorkspaceMode == WorkspaceMode.Current @@ -683,6 +685,11 @@ private async Task RunFreshCandidateAsync( if (selectedAgent is null || selectedDetail is null) throw new TrackerException("AGENT_REQUIRED", "An eligible item did not resolve to a supported agent.", 2); + await emit(new WorkerEvent( + PreparingEvent, + selectedDetail.Id.Value, + selectedAgent, + ItemTitle: selectedDetail.Title)); return await ProcessAsync( config, options, repositoryPath, picked.Claim, selectedDetail, selectedAgent, claimantId, kind, emit, cancellationToken); @@ -1188,8 +1195,14 @@ await emit(new WorkerEvent("dry-run", detail.Id.Value, agentName, workspace.Path null, AgentContextSource.ExplicitOption, ClaimantKind: kind, - ClaimantId: claimantId); + ClaimantId: claimantId, + ExecutionPhase: ClaimExecutionPhases.Preparing); var claim = await tracker.ClaimAsync(config, id, context, cancellationToken); + await emit(new WorkerEvent( + PreparingEvent, + detail.Id.Value, + agentName, + ItemTitle: detail.Title)); var targetStatus = options.ToStatus ?? config.DefaultPickTo; if (!string.IsNullOrWhiteSpace(targetStatus) && @@ -1263,9 +1276,15 @@ await emit(new WorkerEvent("dry-run", id.Value, agentName, workspace.Path, AgentContextSource.ExplicitOption, ClaimantKind: ClaimantKind.Agent, ClaimantId: claimantId, - ClaimToken: currentClaimToken); + ClaimToken: currentClaimToken, + ExecutionPhase: ClaimExecutionPhases.Preparing); var claim = await tracker.TakeoverAsync(config, id, takeoverContext, currentClaimToken, cancellationToken); + await emit(new WorkerEvent( + PreparingEvent, + detail.Id.Value, + agentName, + ItemTitle: detail.Title)); var claimContext = takeoverContext with { ClaimToken = claim.ClaimToken }; var grant = new ClaimHandle(claimContext, claim.ClaimToken); detail = await ClearDispatchStateAsync( @@ -1358,9 +1377,15 @@ await emit(new WorkerEvent( session.SessionId, AgentContextSource.ExplicitOption, ClaimantKind: ClaimantKind.Agent, - ClaimantId: claimantId); + ClaimantId: claimantId, + ExecutionPhase: ClaimExecutionPhases.Preparing); var claim = await tracker.ClaimAsync( config, detail.Id, context, cancellationToken); + await emit(new WorkerEvent( + PreparingEvent, + detail.Id.Value, + agentName, + ItemTitle: detail.Title)); var claimContext = context with { ClaimToken = claim.ClaimToken }; var grant = new ClaimHandle(claimContext, claim.ClaimToken); @@ -1881,6 +1906,11 @@ private async Task ExecuteHandoffAsync( var (claimantId, claimContext, grant, handle) = await AcquireHandoffClaimAsync( launch, adapter, cancellationToken); + await emit(new WorkerEvent( + PreparingEvent, + detail.Id.Value, + targetAgent, + ItemTitle: detail.Title)); var revalidated = await tracker.GetAsync(config, detail.Id, cancellationToken); var postClaimRequest = new LaunchPreflightRequest( @@ -2031,7 +2061,8 @@ await emit(new WorkerEvent( null, AgentContextSource.ExplicitOption, ClaimantKind: ClaimantKind.Agent, - ClaimantId: claimantId); + ClaimantId: claimantId, + ExecutionPhase: ClaimExecutionPhases.Preparing); var claim = await tracker.ClaimAsync( launch.Config, launch.Detail.Id, context, cancellationToken); var claimGeneration = claim.ClaimToken @@ -2382,7 +2413,8 @@ private static (SessionHandle Handle, AgentExecutionContext Context, ClaimHandle AgentContextSource.ExplicitOption, ClaimantKind: kind, ClaimantId: claimantId, - ClaimToken: claimToken); + ClaimToken: claimToken, + ExecutionPhase: ClaimExecutionPhases.Preparing); return (handle, context, new ClaimHandle(context, claimToken)); } @@ -2626,6 +2658,22 @@ private async Task RunClaimedAsync( int recoveryAttempt = 0, DispatchInfo? recoveryDispatch = null) { + // Lease renewals straddle process startup. Leaving their phase unspecified preserves + // whichever durable phase is current: preparing before Process.Start, invoking after the + // process-start callback records that transition. + var leaseGrant = run.Grant with + { + Claimant = run.Grant.Claimant with { ExecutionPhase = null } + }; + var invokingContext = run.ClaimContext with + { + ExecutionPhase = ClaimExecutionPhases.Invoking + }; + run = run with + { + ClaimContext = invokingContext, + Grant = run.Grant with { Claimant = invokingContext } + }; var (config, options, detail, agentName, _, claimContext, grant, workspace, invocation, _, _, invocationKind, expectedSessionId, _, _, assessment) = run; @@ -2642,7 +2690,7 @@ private async Task RunClaimedAsync( run, runControl, observation); - var leaseTask = KeepAliveAsync(config, detail.Id, grant, workspace.Path, + var leaseTask = KeepAliveAsync(config, detail.Id, leaseGrant, workspace.Path, startedAt, deadline, initialClaimExpiresAt, options, emit, runCts, leaseCts.Token, () => fenceState.Fenced = true, () => runCts.Cancel()); @@ -2740,29 +2788,64 @@ await emit(new WorkerEvent(FencedEvent, detail.Id.Value, agentName, new EndedRun(detail, agentName, grant, workspace, result, sessionId), adapter, emit, cancellationToken, recoveryAttempt); - Task RunAgentProcessAsync() => processes.RunAsync( + Task RunAgentProcessAsync() => processes.RunWithCallbacksAsync( invocation, adapter, options.ItemTimeout, environment, - (sessionId, token) => - { - observation.ObservedSessionId = sessionId; - if (invocationKind == AgentInvocationKind.Resume && - expectedSessionId is not null && - !SessionIdsEqual(expectedSessionId, sessionId)) + new AgentProcessCallbacks( + token => RecordAgentProcessStartedAsync( + config, detail, agentName, workspace, grant, token), + (sessionId, token) => { - observation.UnexpectedSessionId = sessionId; - return Task.CompletedTask; - } - return RecordSessionAsync( - config, detail, agentName, workspace, grant, sessionId, token, - fenceState, runCts, emit, run.Selection); - }, + observation.ObservedSessionId = sessionId; + if (invocationKind == AgentInvocationKind.Resume && + expectedSessionId is not null && + !SessionIdsEqual(expectedSessionId, sessionId)) + { + observation.UnexpectedSessionId = sessionId; + return Task.CompletedTask; + } + return RecordSessionAsync( + config, detail, agentName, workspace, grant, sessionId, token, + fenceState, runCts, emit, run.Selection); + }), options.OnFenced == FencedAction.Kill, runCts.Token); } + private async Task RecordAgentProcessStartedAsync( + TrackerConfig config, + WorkItemDetail detail, + string agentName, + Workspace workspace, + ClaimHandle grant, + CancellationToken cancellationToken) + { + try + { + await tracker.RenewClaimAsync( + config, detail.Id, grant, workspace.Path, grant.Claimant.SessionId, + workspace.Branch, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + var detailCode = exception is TrackerException trackerException + ? $" ({trackerException.Code})" + : string.Empty; + throw new TrackerException( + "AGENT_START_FAILED", + $"{AgentDisplayName(agentName)} started, but Wrighty could not record its active " + + $"invocation for '{detail.Id}'{detailCode}.", + 7, + innerException: exception); + } + } + private IDisposable? PrepareInterruptionJournal( ClaimedRun run, WorkerRunControl? runControl, diff --git a/src/Highbyte.Wrighty.Web/Assets/app.js b/src/Highbyte.Wrighty.Web/Assets/app.js index c38e21d2..cb52e316 100644 --- a/src/Highbyte.Wrighty.Web/Assets/app.js +++ b/src/Highbyte.Wrighty.Web/Assets/app.js @@ -29,6 +29,7 @@ import { toggleSortDirection, dismissBoardFilterMenu, dismissHeaderPopovers, + toggleHeaderPopover, syncBoardFilterIndicator, clearBoardFilters, resetBoardView, @@ -67,6 +68,7 @@ const boardFilterMenu = document.querySelector("#board-filter-menu"); let boardRevision = null; let agentsRevision = null; let workerSummaryRevision = null; +let workerOverviewRevision = null; let lastOpenedItem = null; let authenticationReadyDispatched = false; let boardControlFocus = null; @@ -77,7 +79,10 @@ let hostedLogViews = []; let agentsMenuOpen = false; let agentDetailsOpen = null; const agentSkillScopes = new Map(); -let workerProcessesRevealPending = false; +let workerMenuOpen = false; +let workerProcessesRevealPending = null; +let focusedWorkerRunId = null; +let focusedWorkerTimer = null; syncBoardFilterIndicator(boardFilters, boardFilterMenu); @@ -100,14 +105,26 @@ function refreshAgents() { function refreshWorkerSummary() { const workerSummary = document.querySelector("#worker-summary-region"); if (workerSummary && document.visibilityState === "visible" && + !workerSummary.querySelector(".worker-menu[open]") && !workerSummary.matches(".htmx-request")) { - workerSummary.dispatchEvent(new CustomEvent("wrighty:refresh")); + workerSummary.dispatchEvent(new CustomEvent("wrighty:worker-summary-refresh")); } } +function refreshWorkerOverview() { + const menu = document.querySelector("#worker-summary-region .worker-menu"); + const content = document.querySelector("#worker-overview-content"); + const requestInFlight = content?.matches?.(".htmx-request") || + content?.querySelector?.(".htmx-request"); + if (!menu?.open || !content || document.visibilityState !== "visible" || requestInFlight) + return; + content.dispatchEvent(new CustomEvent("wrighty:worker-overview-refresh")); +} + function refreshDashboard() { refreshBoard(); refreshWorkerSummary(); + refreshWorkerOverview(); refreshAgents(); refreshVisibleOperations(document); } @@ -127,6 +144,7 @@ document.addEventListener("wrighty:refresh", () => { boardRevision = null; agentsRevision = null; workerSummaryRevision = null; + workerOverviewRevision = null; refreshDashboard(); }); @@ -191,8 +209,10 @@ function visibleCountDescription(count, total, archived, filtered) { function dispatchAuthenticationReady() { if (authenticationReadyDispatched || (tokenAuthenticationRequired && !token)) return; + const htmx = globalThis.htmx; + if (!htmx) return; authenticationReadyDispatched = true; - readyPageRegions(document, globalThis.htmx); + readyPageRegions(document, htmx); } const contextPanel = createContextPanelController({ @@ -230,17 +250,39 @@ function selectTab(tab) { } function revealPendingWorkerProcesses() { - if (!workerProcessesRevealPending || !revealWorkerProcesses(document)) return false; - workerProcessesRevealPending = false; + if (workerProcessesRevealPending === null) return false; + const runId = workerProcessesRevealPending || null; + if (!revealWorkerProcesses(document, runId)) return false; + workerProcessesRevealPending = null; return true; } -function openWorkerProcesses() { +function focusWorkerForDetails(runId) { + focusedWorkerRunId = runId; + clearTimeout(focusedWorkerTimer); + if (!runId) return; + document.getElementById(`worker-instance-${runId}`) + ?.classList.add("worker-row-focused"); + focusedWorkerTimer = setTimeout(() => { + document.getElementById(`worker-instance-${runId}`) + ?.classList.remove("worker-row-focused"); + if (focusedWorkerRunId === runId) focusedWorkerRunId = null; + }, 6000); +} + +function closeWorkerOverview() { + const menu = document.querySelector("#worker-summary-region .worker-menu"); + if (menu?.open) menu.open = false; +} + +function openWorkerProcesses(runId = null) { const operationsTab = document.querySelector("#tab-operations"); if (!operationsTab) return; selectTabWithSettingsGuard(operationsTab, { afterSelect() { - workerProcessesRevealPending = true; + closeWorkerOverview(); + focusWorkerForDetails(runId); + workerProcessesRevealPending = runId || ""; requestAnimationFrame(revealPendingWorkerProcesses); } }); @@ -382,6 +424,10 @@ document.addEventListener("htmx:configRequest", event => { if (workerSummaryRevision && url.includes("handler=WorkerSummary")) { event.detail.headers["If-None-Match"] = `"${workerSummaryRevision}"`; } + if (workerOverviewRevision && url.includes("handler=WorkerOverview") && + document.querySelector("#worker-overview-content [data-worker-overview-revision]")) { + event.detail.headers["If-None-Match"] = `"${workerOverviewRevision}"`; + } }); document.addEventListener("htmx:beforeRequest", event => { @@ -396,6 +442,11 @@ document.addEventListener("htmx:beforeRequest", event => { agentSkillScopes.set(agentDetailsOpen, selectedScope); } } + const workerSummary = event.detail.target?.closest?.("#worker-summary-region") || + event.target.closest?.("#worker-summary-region"); + if (workerSummary) { + workerMenuOpen = Boolean(workerSummary.querySelector(".worker-menu")?.open); + } const card = event.target.closest?.(".card"); if (card) lastOpenedItem = card.dataset.itemId; if (event.target.closest?.("[data-board-bulk-action]")) @@ -477,15 +528,35 @@ function restoreWorkerSummaryAfterSwap(target) { const workerSummary = target.closest?.("#worker-summary-region") || document.querySelector("#worker-summary-region"); - if (workerSummary?.dataset.revision) + if (workerSummary?.dataset.revision) { workerSummaryRevision = workerSummary.dataset.revision; + const menu = workerSummary.querySelector(".worker-menu"); + if (menu && workerMenuOpen) { + menu.open = true; + menu.querySelector("#worker-overview-content") + ?.dispatchEvent(new CustomEvent("wrighty:worker-overview-open")); + } + } +} + +function restoreWorkerOverviewAfterSwap(target) { + const overview = target.closest?.("#worker-overview-content") || + document.querySelector("#worker-overview-content"); + const revision = overview?.querySelector("[data-worker-overview-revision]") + ?.dataset.workerOverviewRevision; + if (revision) workerOverviewRevision = revision; } function restoreOperationsAfterSwap(target) { if (target.closest?.("#operations-content")) { restoreOperationsControlFocus(document, operationsControlFocus); operationsControlFocus = null; - if (workerProcessesRevealPending) requestAnimationFrame(revealPendingWorkerProcesses); + if (focusedWorkerRunId) { + document.getElementById(`worker-instance-${focusedWorkerRunId}`) + ?.classList.add("worker-row-focused"); + } + if (workerProcessesRevealPending !== null) + requestAnimationFrame(revealPendingWorkerProcesses); } if (target.classList?.contains("hosted-worker-log") || target.id === "operations-content") { // outerHTML swaps leave detail.target pointing at the detached old node. Resolve against the @@ -501,6 +572,7 @@ document.addEventListener("htmx:afterSwap", event => { restoreBoardAfterSwap(target); restoreAgentsAfterSwap(target); restoreWorkerSummaryAfterSwap(target); + restoreWorkerOverviewAfterSwap(target); restoreOperationsAfterSwap(target); const heading = target.querySelector?.(".detail h2"); @@ -742,6 +814,12 @@ function handleGeneralClick(target) { handleLaunchDialogClick(target); + const workerDetails = target.closest("[data-worker-details-run-id]"); + if (workerDetails) { + openWorkerProcesses(workerDetails.dataset.workerDetailsRunId); + return; + } + if (target.closest("[data-open-worker-processes]")) { openWorkerProcesses(); return; @@ -795,7 +873,17 @@ document.addEventListener("change", event => { document.addEventListener("click", event => { closeTokenPickerPopovers(document, event.target); dismissWorkspaceModeHelp(document, event.target); - dismissHeaderPopovers(document, event.target); + const headerToggle = toggleHeaderPopover(document, event.target); + if (headerToggle) { + event.preventDefault(); + agentsMenuOpen = Boolean(document.querySelector("#agents-region .agents-menu")?.open); + workerMenuOpen = Boolean( + document.querySelector("#worker-summary-region .worker-menu")?.open); + } else if (dismissHeaderPopovers(document, event.target) > 0) { + agentsMenuOpen = Boolean(document.querySelector("#agents-region .agents-menu")?.open); + workerMenuOpen = Boolean( + document.querySelector("#worker-summary-region .worker-menu")?.open); + } const filterClose = event.target.closest?.("[data-close-board-filters]"); if (dismissBoardFilterMenu(boardFilterMenu, event.target) && filterClose) return; if (handleBoardSortClick(event.target)) return; @@ -806,6 +894,21 @@ document.addEventListener("click", event => { handleGeneralClick(event.target); }); +document.addEventListener("toggle", event => { + const agentsMenu = event.target.closest?.("#agents-region .agents-menu"); + if (agentsMenu) { + agentsMenuOpen = agentsMenu.open; + return; + } + const menu = event.target.closest?.("#worker-summary-region .worker-menu"); + if (!menu) return; + workerMenuOpen = menu.open; + if (menu.open) { + menu.querySelector("#worker-overview-content") + ?.dispatchEvent(new CustomEvent("wrighty:worker-overview-open")); + } +}, true); + window.addEventListener("resize", () => refreshExpandableValues()); function handleSearchKeydown(event) { diff --git a/src/Highbyte.Wrighty.Web/Assets/board-controls.mjs b/src/Highbyte.Wrighty.Web/Assets/board-controls.mjs index 8cf6a290..dc5209fc 100644 --- a/src/Highbyte.Wrighty.Web/Assets/board-controls.mjs +++ b/src/Highbyte.Wrighty.Web/Assets/board-controls.mjs @@ -90,16 +90,40 @@ export function dismissHeaderPopovers(doc, target) { // A fast HTMX response can replace a menu before the originating click bubbles here. The new // menu cannot contain the now-detached target, but target.closest() still identifies which kind // of menu the click came from. Keep that replacement open while closing other menu types. - const sourceMenu = target.closest?.(".agents-menu"); + const sourceMenu = target.closest?.(".agents-menu, .worker-menu"); + let sourceMenuClass = null; + if (sourceMenu?.classList?.contains("agents-menu")) { + sourceMenuClass = "agents-menu"; + } else if (sourceMenu?.classList?.contains("worker-menu")) { + sourceMenuClass = "worker-menu"; + } let closed = 0; - doc.querySelectorAll(".agents-menu[open]").forEach(menu => { - if (sourceMenu || menu.contains(target)) return; + doc.querySelectorAll(".agents-menu[open], .worker-menu[open]").forEach(menu => { + if (menu === sourceMenu || menu.contains(target) || + (sourceMenuClass && menu.classList?.contains(sourceMenuClass))) return; menu.open = false; closed += 1; }); return closed; } +export function toggleHeaderPopover(doc, target) { + if (!doc?.querySelectorAll || !target) return null; + const summary = target.closest?.(".agents-menu > summary, .worker-menu > summary"); + const menu = summary?.closest?.(".agents-menu, .worker-menu"); + if (!menu) return null; + + const opening = !menu.open; + doc.querySelectorAll(".agents-menu[open], .worker-menu[open]").forEach(candidate => { + if (candidate !== menu) candidate.open = false; + }); + menu.open = opening; + return { + kind: menu.classList?.contains("agents-menu") ? "agents" : "workers", + open: opening + }; +} + const structuredBoardFilterNames = new Set([ "claimKind", "agent", "priority", "claimState", "updatedWithin" ]); diff --git a/src/Highbyte.Wrighty.Web/Assets/page-regions.mjs b/src/Highbyte.Wrighty.Web/Assets/page-regions.mjs index c92134fe..8dcff02d 100644 --- a/src/Highbyte.Wrighty.Web/Assets/page-regions.mjs +++ b/src/Highbyte.Wrighty.Web/Assets/page-regions.mjs @@ -21,11 +21,15 @@ export const readyRegionSelectors = [ * placeholder with its request never sent. Anything missing here is a page variant, not an error. */ export function readyPageRegions(doc, htmx) { + // A ready event sent before htmx has processed the region is lost forever. The module and the + // deferred htmx script normally initialize in the expected order, but a cold or cached load can + // reverse them. Wait for the later htmx:load callback instead of consuming the one-time startup. + if (!htmx) return []; const regions = readyRegionSelectors .map((selector) => doc.querySelector(selector)) .filter((region) => region !== null && region !== undefined); for (const region of regions) { - htmx?.process(region); + htmx.process(region); region.dispatchEvent(new CustomEvent("wrighty:ready")); } return regions; @@ -50,17 +54,18 @@ export function refreshVisibleOperations(doc) { } /** - * Polls the Agents inventory while capacity probes run, but not while a short mutation is replacing - * the same controls. Probe requests intentionally remain visible through read-only polling so the - * operator sees each acquired probe and its warning state before the vendor call completes. + * Polls the Agents inventory while capacity probes run, but not while the popover is open or a + * short mutation is replacing the same controls. Direct actions still replace the open inventory; + * only background polling pauses, avoiding a visible close/reopen flicker. */ export function refreshAgentsInventory(doc) { const agents = doc.querySelector("#agents-region"); + const menuOpen = agents?.querySelector?.(".agents-menu[open]"); const blockingRequest = agents?.matches?.(".htmx-request") || agents?.querySelector?.(".htmx-request:not([data-agent-probe-request])"); - if (!agents || doc.visibilityState !== "visible" || blockingRequest) return false; + if (!agents || doc.visibilityState !== "visible" || menuOpen || blockingRequest) return false; - agents.dispatchEvent(new CustomEvent("wrighty:refresh")); + agents.dispatchEvent(new CustomEvent("wrighty:agents-refresh")); return true; } @@ -71,10 +76,13 @@ export function refreshAgentsInventory(doc) { * after each replacement, which also pulled the viewport back after the user had scrolled away. * The page tab is stable across swaps and remains the appropriate keyboard focus destination. */ -export function revealWorkerProcesses(doc) { +export function revealWorkerProcesses(doc, runId = null) { doc.querySelector("#tab-operations")?.focus?.({ preventScroll: true }); - const workerProcesses = doc.querySelector("#worker-processes"); + const workerProcesses = runId + ? doc.getElementById?.(`worker-instance-${runId}`) + : doc.querySelector("#worker-processes"); if (!workerProcesses) return false; + if (runId) workerProcesses.classList?.add?.("worker-row-focused"); workerProcesses.scrollIntoView?.({ block: "start", behavior: "auto" }); return true; } diff --git a/src/Highbyte.Wrighty.Web/Assets/wrighty.css b/src/Highbyte.Wrighty.Web/Assets/wrighty.css index 23cf9931..08bf48a6 100644 --- a/src/Highbyte.Wrighty.Web/Assets/wrighty.css +++ b/src/Highbyte.Wrighty.Web/Assets/wrighty.css @@ -56,7 +56,9 @@ button.danger { color: var(--danger-text); } .connection-tools { display: grid; justify-items: end; gap: .12rem; } .access-link-button { min-height: 0; padding: 0; border: 0; background: transparent; color: var(--muted); font-size: .66rem; line-height: 1.2; text-decoration: underline; text-underline-offset: .12rem; white-space: nowrap; } .access-link-button:hover { border-color: transparent; color: var(--accent-text); } -.worker-summary-button { display: flex; align-items: center; gap: .45rem; min-height: 2.4rem; padding: .4rem .7rem; font-size: .8rem; font-weight: 700; white-space: nowrap; } +.worker-summary-button { display: flex; align-items: center; gap: .45rem; min-height: 2.4rem; padding: .4rem .7rem; border: 1px solid var(--line); border-radius: .45rem; background: var(--surface); cursor: pointer; font-size: .8rem; font-weight: 700; white-space: nowrap; } +.worker-summary-button:hover, .worker-menu[open] > .worker-summary-button { border-color: var(--accent); } +.worker-summary-button:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; } .worker-summary-button strong { min-width: 1ch; font-variant-numeric: tabular-nums; text-align: center; } .worker-summary-state { color: var(--muted); font-size: .72rem; font-weight: 600; } .worker-summary-state::before { content: "·"; margin-right: .45rem; color: var(--muted); } @@ -64,6 +66,53 @@ button.danger { color: var(--danger-text); } .worker-summary-button.has-active .worker-summary-active { color: var(--success); } .worker-summary-button.has-attention:not(.has-active) { border-color: color-mix(in srgb, var(--warning) 75%, var(--line)); } .worker-summary-button .worker-summary-attention { color: var(--warning); } +.worker-overview-popover { position: absolute; top: calc(100% - .25rem); right: 1.5rem; z-index: 62; width: min(40rem, calc(100vw - 1.6rem)); max-height: calc(100vh - 6rem); overflow: auto; padding: .75rem; border: 1px solid var(--line); border-radius: .65rem; background: var(--surface-2); box-shadow: var(--shadow); } +.worker-menu:has(.has-active) .worker-overview-popover { border-color: color-mix(in srgb, var(--success) 70%, var(--line)); } +.worker-menu:has(.has-attention):not(:has(.has-active)) .worker-overview-popover { border-color: color-mix(in srgb, var(--warning) 70%, var(--line)); } +#worker-overview-content { display: flex; flex-direction: column; min-height: 16rem; } +.worker-overview-loading { margin: auto; color: var(--muted); } +.worker-overview-header { display: flex; align-items: start; justify-content: space-between; gap: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--line); } +.worker-overview-header h2 { margin: .12rem 0 0; font-size: 1.1rem; } +.worker-overview-header p { margin: .25rem 0 0; color: var(--muted); font-size: .75rem; } +.worker-overview-heading-actions { display: flex; align-items: center; gap: .45rem; } +.worker-overview-heading-actions form { margin: 0; } +.worker-overview-list { + display: grid; + gap: .7rem; + margin: 1rem 0 0; + padding: 0; + list-style: none; +} +.worker-overview-row { display: grid; gap: .65rem; padding: .85rem; border: 1px solid var(--line); border-left-width: .28rem; border-radius: .6rem; background: var(--surface-2); } +.worker-overview-row.worker-overview-working { border-left-color: var(--success); background: color-mix(in srgb, var(--success) 9%, var(--surface)); } +.worker-overview-row.worker-overview-preparing { border-left-color: var(--accent); } +.worker-overview-row.worker-overview-stopping { border-left-color: var(--warning); } +.worker-overview-row-heading { display: flex; align-items: start; justify-content: space-between; gap: .75rem; } +.worker-overview-row-heading > div { display: grid; min-width: 0; gap: .1rem; } +.worker-overview-row-heading strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .85rem; } +.worker-overview-row-heading span, .worker-overview-current-item > span { color: var(--muted); font-size: .68rem; } +.worker-overview-state { flex: none; padding: .18rem .45rem; border-radius: 999px; background: var(--surface); color: var(--text) !important; font-weight: 750; } +.worker-overview-working .worker-overview-state { color: var(--success) !important; } +.worker-overview-stopping .worker-overview-state { color: var(--warning) !important; } +.worker-overview-current-item { display: grid; gap: .12rem; min-width: 0; } +.worker-overview-current-item strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .8rem; } +.worker-overview-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .5rem; margin: 0; } +.worker-overview-facts > div { min-width: 0; } +.worker-overview-facts dt { color: var(--muted); font-size: .6rem; font-weight: 750; letter-spacing: .05em; text-transform: uppercase; } +.worker-overview-facts dd { margin: .12rem 0 0; overflow-wrap: anywhere; font-size: .72rem; } +.worker-overview-actions { display: flex; justify-content: flex-end; gap: .45rem; } +.worker-overview-actions form { margin: 0; } +.worker-overview-actions button { min-height: 2rem; padding: .3rem .65rem; font-size: .72rem; } +.worker-overview-empty { display: grid; justify-items: center; gap: .3rem; margin: auto; padding: 3rem 1rem; color: var(--muted); text-align: center; } +.worker-overview-empty strong { color: var(--text); } +.worker-overview-empty p { margin: 0; font-size: .8rem; } +.worker-overview-footer { display: flex; align-items: center; justify-content: space-between; gap: .7rem; margin-top: 1rem; padding-top: .8rem; border-top: 1px solid var(--line); color: var(--muted); font-size: .72rem; } +.worker-overview-footer button { min-height: 2rem; padding: .25rem .55rem; font-size: .7rem; } +@media (max-width: 620px) { + .worker-overview-header { align-items: stretch; flex-direction: column; } + .worker-overview-heading-actions form button { width: 100%; } + .worker-overview-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} .eyebrow { color: var(--accent-text); font-size: .65rem; font-weight: 800; letter-spacing: .14em; } .connection { font-size: .82rem; color: var(--muted); } .connection[data-state=connected]::before { content: "● "; color: var(--success); } @@ -163,6 +212,8 @@ main { padding: 1rem 1.5rem 2rem; } .worker-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .3rem .7rem; padding: .65rem; border-radius: .5rem; background: var(--surface-2); } .worker-row.worker-stale { border: 1px solid var(--line); background: transparent; } .worker-row.worker-stale strong { color: var(--muted); font-weight: 500; } +.worker-row.worker-row-focused { animation: worker-row-focus 1.8s ease-out; outline: 3px solid color-mix(in srgb, var(--accent) 58%, transparent); outline-offset: 3px; } +@keyframes worker-row-focus { 70%, 100% { outline-color: transparent; } } .worker-row > div { display: grid; min-width: 0; } .worker-row strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .8rem; } .worker-row span, .worker-row small { color: var(--muted); font-size: .7rem; } @@ -523,9 +574,12 @@ input::placeholder, textarea::placeholder { color: var(--muted); } .card.claimed-other { border-color: color-mix(in srgb, var(--warning) 72%, var(--line)); background: color-mix(in srgb, var(--warning) 8%, var(--surface)); } .card.claimed-other::before { background: var(--warning); } .card.activity-needs-attention { border-color: color-mix(in srgb, var(--warning) 78%, var(--line)); background: color-mix(in srgb, var(--warning) 12%, var(--surface)); } -.card.activity-needs-attention::before, .card.activity-queued::before, .card.activity-retry-scheduled::before, .card.activity-handoff-queued::before, .card.activity-agent-active::before, .card.activity-paused::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; } +.card.activity-needs-attention::before, .card.activity-queued::before, .card.activity-retry-scheduled::before, .card.activity-handoff-queued::before, .card.activity-worker-preparing::before, .card.activity-agent-active::before, .card.activity-paused::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; } .card.activity-needs-attention::before { background: var(--warning); } -.card.activity-agent-active::before { background: var(--accent); } +.card.activity-worker-preparing { border-color: color-mix(in srgb, var(--muted) 55%, var(--line)); background: color-mix(in srgb, var(--muted) 5%, var(--surface)); } +.card.activity-worker-preparing::before { background: var(--muted); } +.card.activity-agent-active { transform: translateY(-1px); border-color: var(--accent-strong); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent), 0 .75rem 1.5rem color-mix(in srgb, var(--accent) 28%, transparent); } +.card.activity-agent-active::before { width: 6px; background: var(--accent-strong); } .card.activity-queued::before { background: #7259a8; } .card.activity-retry-scheduled::before { background: #a36a00; } .card.activity-handoff-queued::before { background: #7259a8; } @@ -539,6 +593,18 @@ input::placeholder, textarea::placeholder { color: var(--muted); } .card-title { font-weight: 670; line-height: 1.25; } .activity-badge { width: max-content; max-width: 100%; padding: .12rem .42rem; border-radius: 999px; background: color-mix(in srgb, var(--accent) 13%, var(--surface)); color: var(--accent-text); font-size: .68rem; font-weight: 750; } .activity-needs-attention .activity-badge { background: color-mix(in srgb, var(--warning) 18%, var(--surface)); color: var(--warning); } +.activity-worker-preparing .activity-badge { background: color-mix(in srgb, var(--muted) 14%, var(--surface)); color: var(--text); } +.agent-working-banner { position: relative; display: flex; align-items: center; gap: .6rem; width: calc(100% + 1.5rem); min-width: 0; margin: .05rem -.75rem 0; overflow: hidden; padding: .48rem .75rem .55rem; background: linear-gradient(110deg, var(--accent-strong), var(--accent)); color: var(--accent-contrast); font-size: .72rem; font-weight: 800; letter-spacing: .02em; } +.agent-working-banner::after { content: ""; position: absolute; inset: auto 0 0; height: 3px; background: color-mix(in srgb, var(--accent-contrast) 68%, transparent); } +.agent-working-live { display: inline-flex; align-items: center; gap: .32rem; flex: none; padding-right: .6rem; border-right: 1px solid color-mix(in srgb, var(--accent-contrast) 42%, transparent); font-size: .62rem; letter-spacing: .13em; text-transform: uppercase; } +.agent-working-live::before { content: ""; width: .48rem; height: .48rem; flex: none; border-radius: 999px; background: var(--accent-contrast); box-shadow: 0 0 0 .18rem color-mix(in srgb, var(--accent-contrast) 22%, transparent); } +.agent-working-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +@media (prefers-reduced-motion: no-preference) { + .agent-working-live::before { animation: agent-working-pulse 1.6s ease-in-out infinite; } + .agent-working-banner::after { right: auto; left: -42%; width: 42%; animation: agent-working-rail 2.2s ease-in-out infinite; } +} +@keyframes agent-working-pulse { 50% { opacity: .5; transform: scale(.76); } } +@keyframes agent-working-rail { to { left: 100%; } } .activity-queued .activity-badge { background: color-mix(in srgb, #7259a8 18%, var(--surface)); color: color-mix(in srgb, #7259a8 78%, var(--text)); } .activity-retry-scheduled .activity-badge { background: color-mix(in srgb, #a36a00 18%, var(--surface)); color: color-mix(in srgb, #a36a00 85%, var(--text)); } .activity-handoff-queued .activity-badge { background: color-mix(in srgb, #7259a8 18%, var(--surface)); color: color-mix(in srgb, #7259a8 78%, var(--text)); } @@ -603,6 +669,7 @@ input::placeholder, textarea::placeholder { color: var(--muted); } .worker-state-callout.queued { border-color: color-mix(in srgb, #7259a8 65%, var(--line)); background: color-mix(in srgb, #7259a8 10%, var(--surface)); } .worker-state-callout.retry-scheduled { border-color: color-mix(in srgb, #a36a00 65%, var(--line)); background: color-mix(in srgb, #a36a00 10%, var(--surface)); } .worker-state-callout.handoff-queued { border-color: color-mix(in srgb, #7259a8 65%, var(--line)); background: color-mix(in srgb, #7259a8 10%, var(--surface)); } +.worker-state-callout.worker-preparing { border-color: color-mix(in srgb, var(--muted) 55%, var(--line)); background: color-mix(in srgb, var(--muted) 6%, var(--surface)); } .worker-state-callout.agent-active { border-color: color-mix(in srgb, var(--accent) 70%, var(--line)); background: color-mix(in srgb, var(--accent) 10%, var(--surface)); } .worker-state-callout.completed { border-color: color-mix(in srgb, var(--success, #3a9d5d) 65%, var(--line)); background: color-mix(in srgb, var(--success, #3a9d5d) 10%, var(--surface)); } .worker-state-callout.provider-unavailable { border-color: color-mix(in srgb, #a36a00 65%, var(--line)); background: color-mix(in srgb, #a36a00 10%, var(--surface)); } @@ -768,6 +835,7 @@ input::placeholder, textarea::placeholder { color: var(--muted); } .app-header-status { width: 100%; justify-content: flex-start; gap: .5rem; } .app-header-status .connection-tools { margin-left: auto; } .agents-popover { right: .8rem; } + .worker-overview-popover { right: .8rem; } .agents-popover-header { align-items: stretch; flex-direction: column; } .agents-quick-actions { justify-content: flex-start; } .board { display: grid; grid-auto-flow: row; grid-auto-columns: auto; overflow: visible; } diff --git a/src/Highbyte.Wrighty.Web/HostedWorkerEventProjection.cs b/src/Highbyte.Wrighty.Web/HostedWorkerEventProjection.cs index 8ca86fbd..eb79223b 100644 --- a/src/Highbyte.Wrighty.Web/HostedWorkerEventProjection.cs +++ b/src/Highbyte.Wrighty.Web/HostedWorkerEventProjection.cs @@ -15,14 +15,16 @@ public static HostedWorkerProjectedState Apply( string? agent, WorkerEvent value) { - var running = value.Type is "started" or "resumed" or "running" or "session"; + var preparing = value.Type == "preparing"; + var running = value.Type is "started" or "resumed" or "retry-started" or + "handoff-started" or "requirements-assessment-started" or "running" or "session"; var terminal = value.Type is "finished" or "needs-attention" or "failed" or "fenced" or "timed-out" or "rejected" or "retry-scheduled" or "interrupted"; var stopping = state is WebHostedWorkerState.Draining or WebHostedWorkerState.StoppingNow or WebHostedWorkerState.Finalizing; state = ProjectAmbientState(state, value.Type, stopping); - if (running && value.ItemId is not null) + if ((preparing || running) && value.ItemId is not null) { if (!stopping) state = WebHostedWorkerState.Running; @@ -70,6 +72,7 @@ public static string Level(WorkerEvent value) => { "idle" or "no-item" or "retry-scheduled" or "workspace-busy" or "agent-unavailable" or "provider-unavailable" => SafeMessage(value.Message), + "preparing" => "The worker is preparing the item for its agent.", "started" => "The agent session started.", "resumed" => "The retained agent session resumed.", "running" or "session" => "The agent session is running.", diff --git a/src/Highbyte.Wrighty.Web/ItemOrganization.cs b/src/Highbyte.Wrighty.Web/ItemOrganization.cs index 862c5dca..c685fe57 100644 --- a/src/Highbyte.Wrighty.Web/ItemOrganization.cs +++ b/src/Highbyte.Wrighty.Web/ItemOrganization.cs @@ -211,6 +211,20 @@ private static void Append(StringBuilder builder, string name, IEnumerable activity switch + { + OperationalStatuses.AgentActive => 0, + OperationalStatuses.WorkerPreparing => 1, + OperationalStatuses.NeedsAttention => 2, + OperationalStatuses.RetryScheduled => 3, + OperationalStatuses.HandoffQueued => 4, + OperationalStatuses.Queued => 5, + _ => 6 + }; +} + public sealed class BoardCardComparer( ItemSort sort, IReadOnlyList priorities) : IComparer @@ -236,8 +250,8 @@ public int Compare(BoardCardModel? left, BoardCardModel? right) private int CompareDefault(BoardCardModel left, BoardCardModel right) { - var compared = OperationalRank(left.OperationalStatus).CompareTo( - OperationalRank(right.OperationalStatus)); + var compared = OperationalStatusOrder.Rank(left.OperationalStatus).CompareTo( + OperationalStatusOrder.Rank(right.OperationalStatus)); if (compared != 0) return compared; compared = Optional(Priority(left.Priority), Priority(right.Priority), descending: false); return compared != 0 ? compared : Number(left).CompareTo(Number(right)); @@ -283,15 +297,6 @@ internal static int Number(BoardCardModel card) : int.MaxValue; } - private static int OperationalRank(string activity) => activity switch - { - OperationalStatuses.NeedsAttention => 0, - OperationalStatuses.AgentActive => 1, - OperationalStatuses.RetryScheduled => 2, - OperationalStatuses.HandoffQueued => 3, - OperationalStatuses.Queued => 4, - _ => 5 - }; } public sealed class OperationsListInput @@ -309,6 +314,15 @@ public sealed class OperationsListInput public string? ClaimState { get; set; } } +public sealed class WorkerStopInput +{ + public string RunId { get; set; } = string.Empty; + public int ProcessId { get; set; } + public string? ProcessStartIdentity { get; set; } + public string HostKind { get; set; } = string.Empty; + public string Mode { get; set; } = string.Empty; +} + public sealed record OperationsListQuery( ItemSort Sort, string? Search, @@ -477,8 +491,8 @@ public int Compare(OperationsItemView? left, OperationsItemView? right) private int CompareDefault(OperationsItemView left, OperationsItemView right) { - var compared = OperationalRank(left.OperationalStatus).CompareTo( - OperationalRank(right.OperationalStatus)); + var compared = OperationalStatusOrder.Rank(left.OperationalStatus).CompareTo( + OperationalStatusOrder.Rank(right.OperationalStatus)); if (compared != 0) return compared; compared = Optional(Priority(left.Priority), Priority(right.Priority), descending: false); return compared != 0 ? compared : Number(left.Id).CompareTo(Number(right.Id)); @@ -521,13 +535,4 @@ private static int Number(string id) : int.MaxValue; } - private static int OperationalRank(string activity) => activity switch - { - OperationalStatuses.NeedsAttention => 0, - OperationalStatuses.AgentActive => 1, - OperationalStatuses.RetryScheduled => 2, - OperationalStatuses.HandoffQueued => 3, - OperationalStatuses.Queued => 4, - _ => 5 - }; } diff --git a/src/Highbyte.Wrighty.Web/Pages/Index.cshtml b/src/Highbyte.Wrighty.Web/Pages/Index.cshtml index 0c6c6e58..d9facfe3 100644 --- a/src/Highbyte.Wrighty.Web/Pages/Index.cshtml +++ b/src/Highbyte.Wrighty.Web/Pages/Index.cshtml @@ -30,13 +30,13 @@
Loading workers…
@if (Model.AgentManagementAvailable) {
Loading agents…
} diff --git a/src/Highbyte.Wrighty.Web/Pages/Index.cshtml.cs b/src/Highbyte.Wrighty.Web/Pages/Index.cshtml.cs index b4e80552..763e09cb 100644 --- a/src/Highbyte.Wrighty.Web/Pages/Index.cshtml.cs +++ b/src/Highbyte.Wrighty.Web/Pages/Index.cshtml.cs @@ -30,6 +30,7 @@ public sealed class IndexModel( private const int MaximumBodyLength = 1_000_000; private const string ArgumentInvalid = "ARGUMENT_INVALID"; private const string ContextApprovalPartial = "Shared/_ContextApproval"; + private const string WorkerOverviewPartial = "Shared/_WorkerOverview"; private const string QueueActionId = "queue"; private const string DequeueActionId = "dequeue"; private const string ResumeActionId = "resume"; @@ -89,6 +90,11 @@ private IReadOnlyList AgentPolicyOptions() => descriptorsByName .Select(descriptor => new AgentOptionView(descriptor.Id, descriptor.DisplayName)) .ToArray(); + private IReadOnlyList ExecutionProfileOptions() => + state.Config.EffectiveWorker.EffectiveExecutionProfiles is { Count: > 0 } configuredProfiles + ? configuredProfiles + : BuiltInExecutionProfiles.Names; + public IReadOnlyList PriorityOptions => state.Config.LocalMarkdown?.Priorities ?? []; public string WebAuthenticationMode => @@ -400,12 +406,22 @@ private static bool IsAgentsSurface(string? surface) => public async Task OnPostStartHostedWorkerAsync( [FromForm] OperationsListInput input, + string? surface, CancellationToken cancellationToken) { var result = await hostedWorker.StartAsync(); Response.Headers["HX-Trigger"] = "wrighty:refresh"; + if (IsWorkerOverviewSurface(surface)) + { + return Partial( + WorkerOverviewPartial, + await WorkerOverviewAsync( + cancellationToken, + errorCode: result.Accepted ? null : result.Code, + errorMessage: result.Accepted ? null : result.Message)); + } var feedback = result.Accepted - ? new OperationsFeedback(WorkerNotice: result.Message) + ? new OperationsFeedback() : new OperationsFeedback( WorkerErrorCode: result.Code, WorkerErrorMessage: result.Message); @@ -418,64 +434,89 @@ await OperationsAsync( } public async Task OnPostStopWorkerAsync( - string runId, - int processId, - string? processStartIdentity, - string hostKind, - string mode, + [FromForm] WorkerStopInput stop, [FromForm] OperationsListInput input, + string? surface, CancellationToken cancellationToken) { - if (!Enum.TryParse(hostKind, ignoreCase: true, out var parsedHostKind) || - !Enum.TryParse(mode, ignoreCase: true, out var parsedMode)) + if (!Enum.TryParse(stop.HostKind, ignoreCase: true, out var hostKind) || + !Enum.TryParse(stop.Mode, ignoreCase: true, out var mode)) { - return Partial( - "Shared/_Operations", - await OperationsAsync( - cancellationToken, - new OperationsFeedback( - WorkerErrorCode: "ARGUMENT_INVALID", - WorkerErrorMessage: "The worker stop request was invalid."), - OperationsListQuery.Parse(input))); + var invalid = new HostedWorkerCommandResult( + false, + ArgumentInvalid, + "The worker stop request was invalid."); + return await WorkerStopResponseAsync( + invalid, input, surface, null, cancellationToken); } - HostedWorkerCommandResult result; - if (parsedHostKind == WorkerHostKind.WebHosted && hostedWorker.Owns(runId)) + var result = await RequestWorkerStopAsync(stop, hostKind, mode, cancellationToken); + return await WorkerStopResponseAsync( + result, + input, + surface, + result.Accepted ? stop.RunId : null, + cancellationToken); + } + + private async Task RequestWorkerStopAsync( + WorkerStopInput stop, + WorkerHostKind hostKind, + WorkerStopMode mode, + CancellationToken cancellationToken) + { + if (hostKind == WorkerHostKind.WebHosted && hostedWorker.Owns(stop.RunId)) { - result = parsedMode == WorkerStopMode.Drain - ? hostedWorker.RequestDrain(runId) - : hostedWorker.RequestInterrupt(runId); + return mode == WorkerStopMode.Drain + ? hostedWorker.RequestDrain(stop.RunId) + : hostedWorker.RequestInterrupt(stop.RunId); } - else if (state.Config.SourcePath is not { } configurationPath) + if (state.Config.SourcePath is not { } configurationPath) { - result = new HostedWorkerCommandResult( + return new HostedWorkerCommandResult( false, "WORKER_CONTROL_UNAVAILABLE", "The repository configuration path is unavailable."); } - else - { - var external = await workerInstances.RequestStopAsync( - configurationPath, - new WorkerStopTarget( - runId, - processId, - processStartIdentity, - parsedHostKind), - parsedMode, - cancellationToken); - result = new HostedWorkerCommandResult( - external.Accepted, - external.Code, - external.Message); - } + var external = await workerInstances.RequestStopAsync( + configurationPath, + new WorkerStopTarget( + stop.RunId, + stop.ProcessId, + stop.ProcessStartIdentity, + hostKind), + mode, + cancellationToken); + return new HostedWorkerCommandResult( + external.Accepted, + external.Code, + external.Message); + } + + private async Task WorkerStopResponseAsync( + HostedWorkerCommandResult result, + OperationsListInput input, + string? surface, + string? pendingStopRunId, + CancellationToken cancellationToken) + { var feedback = result.Accepted - ? new OperationsFeedback(WorkerNotice: result.Message) + ? new OperationsFeedback() : new OperationsFeedback( WorkerErrorCode: result.Code, WorkerErrorMessage: result.Message); Response.Headers["HX-Trigger"] = "wrighty:refresh"; + if (IsWorkerOverviewSurface(surface)) + { + return Partial( + WorkerOverviewPartial, + await WorkerOverviewAsync( + cancellationToken, + errorCode: result.Accepted ? null : result.Code, + errorMessage: result.Accepted ? null : result.Message, + pendingStopRunId: pendingStopRunId)); + } return Partial( "Shared/_Operations", await OperationsAsync( @@ -1253,7 +1294,6 @@ private async Task OperationsAsync( .ToArray(), AvailablePriorities: itemsResult.PriorityOptions, AvailableWorkflowStatuses: itemsResult.WorkflowStatusOptions, - WorkerNotice: feedback.WorkerNotice, WorkerErrorCode: feedback.WorkerErrorCode, WorkerErrorMessage: feedback.WorkerErrorMessage); } @@ -1339,6 +1379,23 @@ private async Task> LoadWorkersAsync( } } + private async Task WorkerOverviewAsync( + CancellationToken cancellationToken, + string? errorCode = null, + string? errorMessage = null, + string? pendingStopRunId = null) => + new( + state.LocalHostName, + await LoadWorkersAsync(cancellationToken), + hostedWorker.Snapshots(), + hostedWorker.Available, + errorCode, + errorMessage, + pendingStopRunId); + + private static bool IsWorkerOverviewSurface(string? surface) => + string.Equals(surface, "overview", StringComparison.OrdinalIgnoreCase); + private async Task ContextApprovalAsync( OperationsFeedback feedback, CancellationToken cancellationToken) @@ -1484,21 +1541,20 @@ private async Task return []; } - var catalogs = new List(); - foreach (var agent in adaptersByName.Keys.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)) - { - try - { - catalogs.Add(await modelDiscoveries.DiscoverAsync(agent, cancellationToken)); - } - catch (Exception exception) when (exception is not OperationCanceledException) + return await Task.WhenAll(adaptersByName.Keys + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .Select(async agent => { - catalogs.Add(Workers.AgentModelCatalog.Unavailable( - agent, Workers.ModelDiscoveryFailure.Unavailable)); - } - } - - return catalogs; + try + { + return await modelDiscoveries.DiscoverAsync(agent, cancellationToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return Workers.AgentModelCatalog.Unavailable( + agent, Workers.ModelDiscoveryFailure.Unavailable); + } + })); } private async Task LoadOperationalItemsAsync( @@ -1887,7 +1943,6 @@ private sealed record OperationsFeedback( string? ContextErrorMessage = null, ExecutionContextResult? ContextResult = null, bool ContextRenewed = false, - string? WorkerNotice = null, string? WorkerErrorCode = null, string? WorkerErrorMessage = null); @@ -2025,6 +2080,19 @@ public async Task OnGetWorkerSummaryAsync( return Partial("Shared/_WorkerSummary", summary); } + public async Task OnGetWorkerOverviewAsync( + CancellationToken cancellationToken) + { + var overview = await WorkerOverviewAsync(cancellationToken); + var etag = $"\"{overview.Revision}\""; + if (Request.Headers.IfNoneMatch.Any(value => + string.Equals(value, etag, StringComparison.Ordinal))) + return StatusCode(StatusCodes.Status204NoContent); + + Response.Headers.ETag = etag; + return Partial(WorkerOverviewPartial, overview); + } + public async Task OnGetItemAsync(string id, CancellationToken cancellationToken) { try { return Partial("Shared/_ItemDetail", await Item(id, cancellationToken: cancellationToken)); } @@ -2172,11 +2240,15 @@ public IActionResult OnGetCreate() null, false, null, + null, CreationAttempt.NormalizeOrCreate(null), creationStatuses, local.Priorities, state.Config.EffectiveWorker.UseWorkerQueue, state.Config.DefaultPickFrom, + RepositoryDefaultAgentLabel(), + state.Config.EffectiveWorker.DefaultExecutionProfile, + ExecutionProfileOptions(), AvailableAgents: AgentPolicyOptions())); } @@ -2187,6 +2259,7 @@ public async Task OnPostCreateAsync( string? priority, bool automaticExecutionAllowed, string? agentPolicy, + string? executionProfile, string creationAttemptId, CancellationToken cancellationToken) { @@ -2210,11 +2283,15 @@ public async Task OnPostCreateAsync( string.IsNullOrWhiteSpace(priority) ? null : priority, effectiveAutomaticExecutionAllowed, string.IsNullOrWhiteSpace(agentPolicy) ? null : agentPolicy, + string.IsNullOrWhiteSpace(executionProfile) ? null : executionProfile, creationAttemptId, creationStatuses, local.Priorities, queueAuthorizesExecution, state.Config.DefaultPickFrom, + RepositoryDefaultAgentLabel(), + state.Config.EffectiveWorker.DefaultExecutionProfile, + ExecutionProfileOptions(), AvailableAgents: AgentPolicyOptions()); if (body.Length > MaximumBodyLength) @@ -2237,7 +2314,8 @@ public async Task OnPostCreateAsync( status, draft.Priority, AutomaticExecutionAllowed: effectiveAutomaticExecutionAllowed, - AgentPolicy: draft.AgentPolicy), + AgentPolicy: draft.AgentPolicy, + ExecutionProfile: draft.ExecutionProfile), creationAttemptId, cancellationToken); return ClosePanelAndRefresh(); @@ -4305,7 +4383,9 @@ private async Task Item( workspaceView), CanDelete: canDelete, ExecutionProfile: item.ExecutionProfile, - ExecutionProfiles: state.Config.Worker?.EffectiveExecutionProfiles ?? [], + ExecutionProfiles: ExecutionProfileOptions(), + RepositoryDefaultAgentLabel: RepositoryDefaultAgentLabel(), + RepositoryDefaultExecutionProfile: state.Config.EffectiveWorker.DefaultExecutionProfile, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, QueueAuthorizesExecution: state.Config.EffectiveWorker.UseWorkerQueue, @@ -5247,6 +5327,14 @@ private static string ResponseRevision( : value.Trim().ToLowerInvariant(); } + private string? RepositoryDefaultAgentLabel() + { + var defaultAgent = state.Config.EffectiveWorker.DefaultAgent; + return string.IsNullOrWhiteSpace(defaultAgent) + ? null + : AgentDisplayName(defaultAgent); + } + private HashSet InstalledProbeAgents() => agentRuntimeCatalog.Snapshot().InstalledAgents .Select(runtime => runtime.Agent) diff --git a/src/Highbyte.Wrighty.Web/Pages/Shared/_Agents.cshtml b/src/Highbyte.Wrighty.Web/Pages/Shared/_Agents.cshtml index a787ea22..8a0080ac 100644 --- a/src/Highbyte.Wrighty.Web/Pages/Shared/_Agents.cshtml +++ b/src/Highbyte.Wrighty.Web/Pages/Shared/_Agents.cshtml @@ -82,7 +82,7 @@
"activity-needs-attention", + OperationalStatuses.WorkerPreparing => "activity-worker-preparing", OperationalStatuses.AgentActive => "activity-agent-active", OperationalStatuses.Queued => "activity-queued", OperationalStatuses.RetryScheduled => "activity-retry-scheduled", @@ -28,6 +29,7 @@ ? $"{Model.ProviderBlock.AgentLabel} unavailable" : $"{Model.ProviderBlock.AgentLabel} probe active"; } + var agentWorking = Model.OperationalStatus == OperationalStatuses.AgentActive && !providerBlocked; } @* Razor omits an attribute whose value is null, which is how a non-draggable card loses its draggable attribute entirely rather than carrying an encoded one. *@ @@ -39,11 +41,18 @@ hx-target="#item-panel" hx-swap="innerHTML"> @Model.DisplayId@if (Model.Priority is not null){@Model.Priority} @Model.Title + @if (agentWorking) + { + + Live + @activityLabel + + } @* The claim label shares the badge's row, pushed right. The bottom of the card belongs to the action buttons, which are positioned over it; leaving the claim state down there put quiet, always-present text underneath controls that grow with the item's state. *@ - @if (activityLabel is not null) + @if (activityLabel is not null && !agentWorking) { @activityLabel } diff --git a/src/Highbyte.Wrighty.Web/Pages/Shared/_CompleteWorktree.cshtml b/src/Highbyte.Wrighty.Web/Pages/Shared/_CompleteWorktree.cshtml index 56ff1f11..8a7b8316 100644 --- a/src/Highbyte.Wrighty.Web/Pages/Shared/_CompleteWorktree.cshtml +++ b/src/Highbyte.Wrighty.Web/Pages/Shared/_CompleteWorktree.cshtml @@ -5,7 +5,8 @@ surfacing a destructive cleanup there could discard work the operator intends to continue. *@ @{ var sessionStillOwnedOrResuming = Model.OperationalStatus - is OperationalStatuses.AgentActive + is OperationalStatuses.WorkerPreparing + or OperationalStatuses.AgentActive or OperationalStatuses.HumanEditing or OperationalStatuses.AutomationActive or OperationalStatuses.NeedsAttention diff --git a/src/Highbyte.Wrighty.Web/Pages/Shared/_CreateForm.cshtml b/src/Highbyte.Wrighty.Web/Pages/Shared/_CreateForm.cshtml index 62c6101d..5737e4a7 100644 --- a/src/Highbyte.Wrighty.Web/Pages/Shared/_CreateForm.cshtml +++ b/src/Highbyte.Wrighty.Web/Pages/Shared/_CreateForm.cshtml @@ -51,14 +51,23 @@ } -

Creation does not claim the item or start a worker. The agent policy only selects an agent once automatic execution is authorized.

+ +

Creation does not claim the item or start a worker. Agent and execution-profile policies apply once automatic execution is authorized.

diff --git a/src/Highbyte.Wrighty.Web/Pages/Shared/_EditForm.cshtml b/src/Highbyte.Wrighty.Web/Pages/Shared/_EditForm.cshtml index 5c6de1f5..dc9d9349 100644 --- a/src/Highbyte.Wrighty.Web/Pages/Shared/_EditForm.cshtml +++ b/src/Highbyte.Wrighty.Web/Pages/Shared/_EditForm.cshtml @@ -37,7 +37,7 @@ {
", workerSummaryHtml); Assert.Contains("0", workerSummaryHtml); - Assert.Contains("data-open-worker-processes", workerSummaryHtml); + Assert.Contains("
", workerSummaryHtml); + Assert.Contains("class=\"worker-overview-popover\"", workerSummaryHtml); + Assert.Contains("id=\"worker-overview-content\"", workerSummaryHtml); + Assert.Contains("handler=WorkerOverview", workerSummaryHtml); Assert.NotNull(workerSummary.Headers.ETag); + using var workerOverviewRequest = AuthenticatedGet( + host, + $"{host.Origin}/?handler=WorkerOverview"); + using var workerOverview = await client.SendAsync(workerOverviewRequest); + var workerOverviewHtml = await workerOverview.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, workerOverview.StatusCode); + Assert.Contains("id=\"worker-overview-heading\"", workerOverviewHtml); + Assert.Contains("Workers on this installation", workerOverviewHtml); + Assert.Contains("No workers are running", workerOverviewHtml); + Assert.Contains("data-worker-overview-revision=", workerOverviewHtml); + Assert.NotNull(workerOverview.Headers.ETag); + + using var unchangedWorkerOverviewRequest = AuthenticatedGet( + host, + $"{host.Origin}/?handler=WorkerOverview"); + unchangedWorkerOverviewRequest.Headers.IfNoneMatch.Add(workerOverview.Headers.ETag); + using var unchangedWorkerOverview = await client.SendAsync(unchangedWorkerOverviewRequest); + Assert.Equal(HttpStatusCode.NoContent, unchangedWorkerOverview.StatusCode); + using var unchangedWorkerSummaryRequest = AuthenticatedGet( host, $"{host.Origin}/?handler=WorkerSummary"); @@ -643,10 +671,23 @@ public async Task Hosted_worker_log_is_nested_in_its_worker_card() client, host, "StartHostedWorker", - []); - var html = await response.Content.ReadAsStringAsync(); + new Dictionary { ["surface"] = "overview" }); + var overviewHtml = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Workers on this installation", overviewHtml); + Assert.Contains("This web console", overviewHtml); + Assert.Contains("id=\"start-worker-from-overview\"", overviewHtml); + Assert.Contains("class=\"primary button-compact\"", overviewHtml); + Assert.Contains("data-worker-details-run-id=", overviewHtml); + Assert.Contains(">Stop", overviewHtml); + Assert.Contains("name=\"surface\" value=\"overview\"", overviewHtml); + Assert.DoesNotContain("worker-overview-notice", overviewHtml); + + using var operationsRequest = AuthenticatedGet( + host, + $"{host.Origin}/?handler=Operations"); + var html = await (await client.SendAsync(operationsRequest)).Content.ReadAsStringAsync(); var origin = html.IndexOf("Hosted by this web console", StringComparison.Ordinal); Assert.True(origin >= 0, html); var cardStart = html.LastIndexOf(" + { + ["surface"] = "overview", + ["runId"] = HiddenValue(overviewHtml, "runId"), + ["processId"] = HiddenValue(overviewHtml, "processId"), + ["processStartIdentity"] = HiddenValue(overviewHtml, "processStartIdentity"), + ["hostKind"] = HiddenValue(overviewHtml, "hostKind"), + ["mode"] = "Drain" + }); + var stoppedOverviewHtml = await stopResponse.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, stopResponse.StatusCode); + Assert.DoesNotContain("worker-overview-notice", stoppedOverviewHtml); } finally { @@ -929,10 +988,23 @@ public async Task The_console_offers_both_halves_of_a_profile() "id=\"configuration-profiles-form\" class=\"configuration-form-wide\"", html); Assert.Contains("Repository profile names", html); + Assert.Contains(">No repository default", html); + Assert.Contains("title=\"Explain the default agent\"", html); + Assert.Contains("An item Agent policy or an invocation --agent option must select the agent", html); + Assert.Contains("Used as the fallback when neither the invocation nor the item selects an agent", html); + Assert.DoesNotContain("Item or invocation policy", html); Assert.Contains("data-token-label=\"profile\"", html); Assert.Contains("data-allow-create=\"true\"", html); Assert.Contains("