diff --git a/BlogWorkflow.cs b/BlogWorkflow.cs index 2309972..3d27b6f 100644 --- a/BlogWorkflow.cs +++ b/BlogWorkflow.cs @@ -22,16 +22,24 @@ public class BlogWorkflow( // Emits the root span for a workflow run. Activated by the ActivityListener // registered in Program.cs (or an OpenTelemetry TracerProvider). private static readonly ActivitySource s_activitySource = new("BlogWriter.Workflow"); + private static long s_operationVersion; - public async Task RunAsync(ResearchState state, CancellationToken cancellationToken = default) + public async Task RunAsync( + ResearchState state, + CancellationToken cancellationToken = default, + IProgress? output = null) { + long operationVersion = Interlocked.Increment(ref s_operationVersion); + var publisher = new WorkflowOutputPublisher(output, operationVersion); + publisher.PublishLifecycle(WorkflowOutputOutcome.Progress, "Writing workflow started."); + using Activity? activity = s_activitySource.StartActivity("Workflow.Run"); activity?.SetTag("blog.topic", state.MainTask); var bloggerExecutor = new BloggerExecutor(blogger); var researcherExecutor = new ResearcherExecutor(researcher); var authorExecutor = new AuthorExecutor(author); - var reviewerExecutor = new ReviewerExecutor(reviewer); + var reviewerExecutor = new ReviewerExecutor(reviewer, publisher); Workflow workflow = new WorkflowBuilder(bloggerExecutor) .AddEdge(bloggerExecutor, researcherExecutor) @@ -59,14 +67,17 @@ public async Task RunAsync(ResearchState state, CancellationToken { case ExecutorInvokedEvent invoked: logger.LogInformation("[workflow] -> {ExecutorId} started", invoked.ExecutorId); + publisher.PublishLifecycle(WorkflowOutputOutcome.Progress, $"{invoked.ExecutorId} started."); break; case ExecutorCompletedEvent completed: logger.LogInformation("[workflow] {ExecutorId} completed", completed.ExecutorId); + publisher.PublishLifecycle(WorkflowOutputOutcome.Progress, $"{completed.ExecutorId} completed."); break; case ExecutorFailedEvent failed: logger.LogError(failed.Data as Exception, "[workflow] {ExecutorId} failed", failed.ExecutorId); + publisher.PublishLifecycle(WorkflowOutputOutcome.Failure, $"{failed.ExecutorId} failed."); // A token-cap breach must abort the whole run, not just the // node. Re-throw it so it unwinds to the application entry point. @@ -81,6 +92,7 @@ public async Task RunAsync(ResearchState state, CancellationToken case WorkflowOutputEvent { Data: ResearchState finalState }: // The reviewer yielded the final, approved (or revision-capped) state. result = finalState; + publisher.PublishLifecycle(WorkflowOutputOutcome.Success, "Writing workflow completed."); break; } } diff --git a/BlogWriter.Tests/BlogWorkflowTests.cs b/BlogWriter.Tests/BlogWorkflowTests.cs new file mode 100644 index 0000000..4defff7 --- /dev/null +++ b/BlogWriter.Tests/BlogWorkflowTests.cs @@ -0,0 +1,110 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace BlogWriter.Tests; + +public sealed class BlogWorkflowTests +{ + [Fact] + public async Task RunAsync_EmitsLifecycleAndReviewerUpdatesWithoutChangingFinalState() + { + var workflow = new BlogWorkflow( + new TestBlogger(), + new TestResearcher(), + new TestAuthor(), + new TestReviewer(), + NullLogger.Instance); + var output = new WorkflowOutputCollector(); + + var service = new BlogWriterSessionService(workflow, new RecordingStore()); + BlogSession session = await service.StartAsync("topic", output: output); + ResearchState result = session.State; + + Assert.Equal("draft", result.Draft); + Assert.Equal("APPROVED", result.ReviewNotes); + Assert.Contains(output.Updates, update => + update.Kind == WorkflowOutputKind.Lifecycle && + update.Outcome == WorkflowOutputOutcome.Progress); + Assert.Contains(output.Updates, update => + update.Kind == WorkflowOutputKind.ReviewerFeedback && + update.Message == "APPROVED"); + Assert.Contains(output.Updates, update => + update.Kind == WorkflowOutputKind.Lifecycle && + update.Outcome == WorkflowOutputOutcome.Success); + Assert.Equal(output.Updates.Count, output.Updates.Select(update => update.Sequence).Distinct().Count()); + } + + private sealed class TestBlogger : IBloggerAgent + { + public Task InvokeAsync(ResearchState state, CancellationToken cancellationToken = default) => + Task.FromResult(new BloggerDecision("research", state.MainTask)); + + public Task BloggerNodeAsync(ResearchState state, CancellationToken cancellationToken = default) + { + state.NextStep = "research"; + state.CurrentSubTask = state.MainTask; + return Task.FromResult(state); + } + } + + private sealed class TestResearcher : IResearcherAgent + { + public Task InvokeAsync(string query, CancellationToken cancellationToken = default) => + Task.FromResult("finding"); + + public Task ResearchNodeAsync(ResearchState state, CancellationToken cancellationToken = default) + { + state.ResearchFindings.Add("finding"); + return Task.FromResult(state); + } + } + + private sealed class TestAuthor : IAuthorAgent + { + public Task InvokeAsync(ResearchState state, CancellationToken cancellationToken = default) => + Task.FromResult("draft"); + + public Task AuthorNodeAsync(ResearchState state, CancellationToken cancellationToken = default) + { + state.Draft = "draft"; + return Task.FromResult(state); + } + } + + private sealed class TestReviewer : IReviewerAgent + { + public Task InvokeAsync(ResearchState state, CancellationToken cancellationToken = default) => + Task.FromResult("APPROVED"); + + public Task ReviewerNodeAsync(ResearchState state, CancellationToken cancellationToken = default) + { + state.ReviewNotes = "APPROVED"; + return Task.FromResult(state); + } + } + + private sealed class RecordingStore : IBlogSessionStore + { + public Task CreateAsync(ResearchState state, CancellationToken cancellationToken = default) => + Task.FromResult(new BlogSession + { + Id = Guid.NewGuid().ToString("N"), + OwnerId = "owner", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + State = state, + }); + + public Task GetAsync(string sessionId, CancellationToken cancellationToken = default) => + Task.FromResult(null); + + public Task> ListAsync(CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task SaveAsync(BlogSession session, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task DeleteOwnerSessionsAsync(string ownerId, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } +} diff --git a/BlogWriter.Tests/BlogWriterSessionServiceTests.cs b/BlogWriter.Tests/BlogWriterSessionServiceTests.cs index 184c38e..76ab1ad 100644 --- a/BlogWriter.Tests/BlogWriterSessionServiceTests.cs +++ b/BlogWriter.Tests/BlogWriterSessionServiceTests.cs @@ -102,6 +102,21 @@ public async Task ListAndLoadAsync_DelegateToOwnerScopedStore() Assert.Same(existing, loaded); } + [Fact] + public async Task StartAndReviseAsync_ForwardOutputObserverToWorkflow() + { + var workflow = new StubWorkflow(state => state); + var service = new BlogWriterSessionService(workflow, new RecordingStore()); + var output = new Progress(); + + await service.StartAsync("topic", output: output); + Assert.Same(output, workflow.LastOutput); + + BlogSession session = CreateSession("draft", "review"); + await service.ReviseAsync(session, "change it", 500, 900, output: output); + Assert.Same(output, workflow.LastOutput); + } + private static BlogSession CreateSession(string draft, string review) => new() { Id = Guid.NewGuid().ToString("N"), @@ -115,11 +130,16 @@ public async Task ListAndLoadAsync_DelegateToOwnerScopedStore() private sealed class StubWorkflow(Func run) : IBlogWorkflow { public int CallCount { get; private set; } + public IProgress? LastOutput { get; private set; } - public Task RunAsync(ResearchState state, CancellationToken cancellationToken = default) + public Task RunAsync( + ResearchState state, + CancellationToken cancellationToken = default, + IProgress? output = null) { cancellationToken.ThrowIfCancellationRequested(); CallCount++; + LastOutput = output; return Task.FromResult(run(state)); } } diff --git a/BlogWriter.Tests/WorkflowOutputTestDoubles.cs b/BlogWriter.Tests/WorkflowOutputTestDoubles.cs new file mode 100644 index 0000000..e8e7474 --- /dev/null +++ b/BlogWriter.Tests/WorkflowOutputTestDoubles.cs @@ -0,0 +1,8 @@ +namespace BlogWriter.Tests; + +internal sealed class WorkflowOutputCollector : IProgress +{ + public List Updates { get; } = []; + + public void Report(WorkflowOutputUpdate value) => Updates.Add(value); +} diff --git a/BlogWriter.Tests/WorkflowOutputUpdateTests.cs b/BlogWriter.Tests/WorkflowOutputUpdateTests.cs new file mode 100644 index 0000000..8545d54 --- /dev/null +++ b/BlogWriter.Tests/WorkflowOutputUpdateTests.cs @@ -0,0 +1,51 @@ +using Xunit; + +namespace BlogWriter.Tests; + +public sealed class WorkflowOutputUpdateTests +{ + [Fact] + public void Create_RequiresUserVisibleMessage() + { + Assert.Throws(() => WorkflowOutputUpdate.Create( + WorkflowOutputKind.Lifecycle, + WorkflowOutputOutcome.Progress, + "", + operationVersion: 1, + sequence: 1, + updateKey: "op-1")); + } + + [Fact] + public void Create_PreservesRoutingAndOrderingMetadata() + { + WorkflowOutputUpdate update = WorkflowOutputUpdate.Create( + WorkflowOutputKind.ReviewerFeedback, + WorkflowOutputOutcome.Review, + "Needs a stronger conclusion.", + operationVersion: 4, + sequence: 7, + updateKey: "op-4-review-1", + revisionNumber: 1); + + Assert.Equal(WorkflowOutputKind.ReviewerFeedback, update.Kind); + Assert.Equal(WorkflowOutputOutcome.Review, update.Outcome); + Assert.Equal("Needs a stronger conclusion.", update.Message); + Assert.Equal(4, update.OperationVersion); + Assert.Equal(7, update.Sequence); + Assert.Equal("op-4-review-1", update.UpdateKey); + Assert.Equal(1, update.RevisionNumber); + } + + [Fact] + public void Create_RequiresStableUpdateKey() + { + Assert.Throws(() => WorkflowOutputUpdate.Create( + WorkflowOutputKind.Lifecycle, + WorkflowOutputOutcome.Success, + "Complete", + operationVersion: 1, + sequence: 1, + updateKey: " ")); + } +} diff --git a/BlogWriter.Web.Tests/BlogWorkspaceOutputTestHelpers.cs b/BlogWriter.Web.Tests/BlogWorkspaceOutputTestHelpers.cs new file mode 100644 index 0000000..fe92c3a --- /dev/null +++ b/BlogWriter.Web.Tests/BlogWorkspaceOutputTestHelpers.cs @@ -0,0 +1,34 @@ +using BlogWriter.Web.Services; + +namespace BlogWriter.Web.Tests; + +internal static class BlogWorkspaceOutputTestHelpers +{ + public static WorkflowOutputUpdate Lifecycle( + string message, + long operationVersion = 1, + long sequence = 1, + WorkflowOutputOutcome outcome = WorkflowOutputOutcome.Progress) => + WorkflowOutputUpdate.Create( + WorkflowOutputKind.Lifecycle, + outcome, + message, + operationVersion, + sequence, + $"lifecycle-{operationVersion}-{sequence}"); + + public static WorkflowOutputUpdate Review( + string message, + string updateKey, + long operationVersion = 1, + long sequence = 1, + int revisionNumber = 0) => + WorkflowOutputUpdate.Create( + WorkflowOutputKind.ReviewerFeedback, + WorkflowOutputOutcome.Review, + message, + operationVersion, + sequence, + updateKey, + revisionNumber); +} diff --git a/BlogWriter.Web.Tests/BlogWorkspaceServiceTests.cs b/BlogWriter.Web.Tests/BlogWorkspaceServiceTests.cs index 1807385..abc1baa 100644 --- a/BlogWriter.Web.Tests/BlogWorkspaceServiceTests.cs +++ b/BlogWriter.Web.Tests/BlogWorkspaceServiceTests.cs @@ -31,6 +31,99 @@ public async Task SubmitInitialAsync_PublishesCompletedDraftAndReview() Assert.Equal(1, sessions.StartCalls); } + [Fact] + public async Task SubmitInitialAsync_AppendsLifecycleAndReviewerOutput() + { + var sessions = new StubSessionService(); + var workspace = new BlogWorkspaceService(sessions, TimeSpan.FromMilliseconds(25)); + workspace.State.InitialPrompt = "topic"; + + await workspace.SubmitInitialAsync(); + + Assert.Contains(workspace.State.WorkflowLog, entry => entry.Message == "Writing in progress..."); + Assert.Contains("review", workspace.State.Review); + Assert.DoesNotContain("review", workspace.State.WorkflowLog.Select(entry => entry.Message)); + } + + [Fact] + public async Task ReviewerOutput_AccumulatesAcrossRevisionsAndResetsForNewSession() + { + var sessions = new StubSessionService(); + var workspace = new BlogWorkspaceService(sessions, TimeSpan.FromMilliseconds(25)); + workspace.State.InitialPrompt = "topic"; + await workspace.SubmitInitialAsync(); + workspace.State.RevisionPrompt = "revise"; + + await workspace.SubmitRevisionAsync(); + + Assert.Contains("review", workspace.State.Review); + Assert.Contains("revision review", workspace.State.Review); + Assert.Equal(2, workspace.State.Review.Split("\n\n", StringSplitOptions.None).Length); + + await workspace.NewAsync(discardConfirmed: true); + + Assert.Empty(workspace.State.Review); + Assert.Empty(workspace.State.WorkflowLog); + } + + [Fact] + public async Task LateOutputFromSupersededOperationDoesNotChangeWorkspace() + { + var sessions = new StubSessionService { PendingStart = new TaskCompletionSource() }; + var workspace = new BlogWorkspaceService(sessions, TimeSpan.FromMilliseconds(25)); + workspace.State.InitialPrompt = "topic"; + Task submission = workspace.SubmitInitialAsync(); + await sessions.Started.Task; + IProgress output = sessions.LastOutput!; + + await workspace.NewAsync(discardConfirmed: true); + output.Report(WorkflowOutputUpdate.Create( + WorkflowOutputKind.ReviewerFeedback, + WorkflowOutputOutcome.Review, + "late review", + operationVersion: 1, + sequence: 99, + updateKey: "late")); + sessions.PendingStart.SetResult(CreateSession("late")); + await submission; + + Assert.Empty(workspace.State.Review); + Assert.DoesNotContain(workspace.State.WorkflowLog, entry => entry.Message == "late review"); + } + + [Fact] + public async Task DuplicateReviewerOutputIsRenderedOnce() + { + var sessions = new StubSessionService(); + var workspace = new BlogWorkspaceService(sessions, TimeSpan.FromMilliseconds(25)); + workspace.State.InitialPrompt = "topic"; + + await workspace.SubmitInitialAsync(); + sessions.LastOutput!.Report(BlogWorkspaceOutputTestHelpers.Review("review", "initial-review")); + + Assert.Equal(1, workspace.State.Review.Split("\n\n", StringSplitOptions.None).Length); + } + + [Fact] + public async Task LoadingSessionSeedsReviewerNotesAndClearsTransientLog() + { + var sessions = new StubSessionService + { + Summaries = [CreateSummary("saved")], + SessionToLoad = CreateSession("saved draft", "stored review"), + }; + var workspace = new BlogWorkspaceService(sessions, TimeSpan.FromMilliseconds(25)); + workspace.State.InitialPrompt = "topic"; + await workspace.SubmitInitialAsync(); + await workspace.ListAsync(discardConfirmed: true); + workspace.State.SelectionInput = "1"; + + await workspace.LoadSelectionAsync(); + + Assert.Equal("stored review", workspace.State.Review); + Assert.Empty(workspace.State.WorkflowLog); + } + [Fact] public async Task SubmitInitialAsync_UsesVisibleWordRangeAndAcceptsIt() { @@ -215,7 +308,8 @@ public async Task SubmitRevisionAsync_PublishesCompletedRevision() await workspace.SubmitRevisionAsync(); Assert.Equal("revised: make it shorter", workspace.State.Draft); - Assert.Equal("revision review", workspace.State.Review); + Assert.Contains("review", workspace.State.Review); + Assert.Contains("revision review", workspace.State.Review); Assert.Empty(workspace.State.RevisionPrompt); } @@ -278,10 +372,14 @@ public async Task ProcessingEditsRemainVisibleAndUnsavedAfterSubmittedRangeCompl private static BlogSessionSummary CreateSummary(string task) => new(Guid.NewGuid().ToString("N"), task, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); + private static BlogSession CreateSession(string draft, string review) => + CreateSession(draft, ResearchState.DefaultMinWords, ResearchState.DefaultMaxWords, review); + private static BlogSession CreateSession( string draft, int minWords = ResearchState.DefaultMinWords, - int maxWords = ResearchState.DefaultMaxWords) => new() + int maxWords = ResearchState.DefaultMaxWords, + string review = "review") => new() { Id = Guid.NewGuid().ToString("N"), OwnerId = "owner", @@ -293,7 +391,7 @@ private static BlogSession CreateSession( MinWords = minWords, MaxWords = maxWords, Draft = draft, - ReviewNotes = "review", + ReviewNotes = review, }, }; @@ -307,10 +405,19 @@ private sealed class StubSessionService : IBlogWriterSessionService public BlogSession? SessionToLoad { get; init; } public TaskCompletionSource? PendingStart { get; init; } public TaskCompletionSource Started { get; } = new(); + public IProgress? LastOutput { get; private set; } - public Task StartAsync(string prompt, int minWords = ResearchState.DefaultMinWords, int maxWords = ResearchState.DefaultMaxWords, CancellationToken cancellationToken = default) + public Task StartAsync(string prompt, int minWords = ResearchState.DefaultMinWords, int maxWords = ResearchState.DefaultMaxWords, CancellationToken cancellationToken = default, IProgress? output = null) { StartCalls++; + LastOutput = output; + output?.Report(WorkflowOutputUpdate.Create( + WorkflowOutputKind.ReviewerFeedback, + WorkflowOutputOutcome.Review, + "review", + operationVersion: 1, + sequence: 1, + updateKey: "initial-review")); LastStartRange = new WordRange(minWords, maxWords); Started.TrySetResult(); return PendingStart?.Task ?? Task.FromResult(CreateSession($"draft: {prompt}", minWords, maxWords)); @@ -321,8 +428,17 @@ public Task ReviseAsync( string revision, int minWords, int maxWords, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + IProgress? output = null) { + LastOutput = output; + output?.Report(WorkflowOutputUpdate.Create( + WorkflowOutputKind.ReviewerFeedback, + WorkflowOutputOutcome.Review, + "revision review", + operationVersion: 2, + sequence: 1, + updateKey: "revision-review")); LastRevisionRange = new WordRange(minWords, maxWords); return Task.FromResult(new BlogSession { diff --git a/BlogWriter.Web.Tests/HomePageTests.cs b/BlogWriter.Web.Tests/HomePageTests.cs index fdeac16..88c8f3d 100644 --- a/BlogWriter.Web.Tests/HomePageTests.cs +++ b/BlogWriter.Web.Tests/HomePageTests.cs @@ -104,6 +104,30 @@ public void Home_UsesAccessibleRegionsLabelsAndLiveStatus() Assert.NotNull(cut.Find("[aria-labelledby='review-heading']")); } + [Fact] + public void Home_PlacesWorkflowLogDirectlyBelowCommandBar() + { + RegisterWorkspace(); + IRenderedComponent cut = Render(); + string markup = cut.Markup; + + Assert.True(markup.IndexOf("command-bar", StringComparison.Ordinal) < + markup.IndexOf("workflow-log", StringComparison.Ordinal)); + Assert.DoesNotContain("status-stack", markup); + } + + [Fact] + public void Home_PreservesKeyboardOrderFromCommandsToLogToContent() + { + RegisterWorkspace(); + IRenderedComponent cut = Render(); + string markup = cut.Markup; + + Assert.True(markup.IndexOf("command-bar", StringComparison.Ordinal) < + markup.IndexOf("workflow-log", StringComparison.Ordinal)); + Assert.Equal("0", cut.Find(".workflow-log").GetAttribute("tabindex")); + } + [Fact] public void Home_EnterSubmitsAndShiftEnterDoesNotSubmit() { @@ -163,7 +187,7 @@ private sealed class StubSessionService : IBlogWriterSessionService { public IReadOnlyList Summaries { get; init; } = []; - public Task StartAsync(string prompt, int minWords = ResearchState.DefaultMinWords, int maxWords = ResearchState.DefaultMaxWords, CancellationToken cancellationToken = default) => + public Task StartAsync(string prompt, int minWords = ResearchState.DefaultMinWords, int maxWords = ResearchState.DefaultMaxWords, CancellationToken cancellationToken = default, IProgress? output = null) => Task.FromResult(CreateSession(prompt)); public Task ReviseAsync( @@ -171,7 +195,8 @@ public Task ReviseAsync( string revision, int minWords, int maxWords, - CancellationToken cancellationToken = default) => + CancellationToken cancellationToken = default, + IProgress? output = null) => Task.FromResult(session); public Task> ListAsync(CancellationToken cancellationToken = default) => diff --git a/BlogWriter.Web.Tests/ReviewPaneTests.cs b/BlogWriter.Web.Tests/ReviewPaneTests.cs new file mode 100644 index 0000000..3c7df55 --- /dev/null +++ b/BlogWriter.Web.Tests/ReviewPaneTests.cs @@ -0,0 +1,18 @@ +using Bunit; +using BlogWriter.Web.Components; + +namespace BlogWriter.Web.Tests; + +public sealed class ReviewPaneTests : BunitContext +{ + [Fact] + public void ReviewPane_RendersAccumulatedFeedbackAsText() + { + IRenderedComponent cut = Render(parameters => parameters + .Add(component => component.Content, "first review\n\n")); + + Assert.Contains("first review", cut.Find(".review-copy").TextContent); + Assert.Contains("", cut.Find(".review-copy").TextContent); + Assert.Equal("polite", cut.Find(".pane-content").GetAttribute("aria-live")); + } +} \ No newline at end of file diff --git a/BlogWriter.Web.Tests/WorkflowLogTests.cs b/BlogWriter.Web.Tests/WorkflowLogTests.cs new file mode 100644 index 0000000..7a9502f --- /dev/null +++ b/BlogWriter.Web.Tests/WorkflowLogTests.cs @@ -0,0 +1,32 @@ +using Bunit; +using BlogWriter.Web.Components; +using BlogWriter.Web.Services; + +namespace BlogWriter.Web.Tests; + +public sealed class WorkflowLogTests : BunitContext +{ + [Fact] + public void WorkflowLog_RendersEmptyStateAndAccessibleLiveRegion() + { + IRenderedComponent cut = Render(); + + Assert.Equal("Workflow log", cut.Find("section").GetAttribute("aria-label")); + Assert.Equal("polite", cut.Find("section").GetAttribute("aria-live")); + Assert.Equal("0", cut.Find("section").GetAttribute("tabindex")); + Assert.Contains("No workflow activity yet.", cut.Markup); + } + + [Fact] + public void WorkflowLog_RendersOrderedTextEntries() + { + IRenderedComponent cut = Render(parameters => parameters + .Add(component => component.Entries, [ + new WorkflowLogEntry("started", WorkflowOutputOutcome.Progress), + new WorkflowLogEntry("", WorkflowOutputOutcome.Failure), + ])); + + Assert.Equal(["Progress: started", "Failure: "], cut.FindAll("li").Select(item => item.TextContent).ToArray()); + Assert.Contains("<failed>", cut.Markup); + } +} \ No newline at end of file diff --git a/BlogWriter.Web.Tests/WorkspaceBrowserTests.cs b/BlogWriter.Web.Tests/WorkspaceBrowserTests.cs index de2b02f..87052f5 100644 --- a/BlogWriter.Web.Tests/WorkspaceBrowserTests.cs +++ b/BlogWriter.Web.Tests/WorkspaceBrowserTests.cs @@ -9,4 +9,5 @@ public void RequiredViewports_AreExplicitlyCovered(int width, int height) { Assert.Contains((width, height), new[] { (390, 844), (1440, 900) }); } + } \ No newline at end of file diff --git a/BlogWriter.Web/Components/Pages/Home.razor b/BlogWriter.Web/Components/Pages/Home.razor index f91e3af..a17609e 100644 --- a/BlogWriter.Web/Components/Pages/Home.razor +++ b/BlogWriter.Web/Components/Pages/Home.razor @@ -76,6 +76,8 @@ Quit="() => RequestTransitionAsync(PendingAction.Quit)" ReviseEnabled="@Workspace.State.IsReviseEnabled" /> + + @if (Workspace.State.IsSelectionVisible) {
@@ -88,16 +90,6 @@ } -
- @if (!string.IsNullOrWhiteSpace(Workspace.State.StatusMessage)) - { -

@Workspace.State.StatusMessage

- } - @if (!string.IsNullOrWhiteSpace(Workspace.State.ValidationMessage)) - { - - } -
}
diff --git a/BlogWriter.Web/Components/ReviewPane.razor b/BlogWriter.Web/Components/ReviewPane.razor index 6f983dd..e17fed4 100644 --- a/BlogWriter.Web/Components/ReviewPane.razor +++ b/BlogWriter.Web/Components/ReviewPane.razor @@ -3,7 +3,7 @@ 02

Reviewer notes

-
+
@if (string.IsNullOrWhiteSpace(Content)) {

Reviewer feedback will appear here.

diff --git a/BlogWriter.Web/Components/WorkflowLog.razor b/BlogWriter.Web/Components/WorkflowLog.razor new file mode 100644 index 0000000..659b827 --- /dev/null +++ b/BlogWriter.Web/Components/WorkflowLog.razor @@ -0,0 +1,23 @@ +
+

Workflow log

+ @if (Entries.Count == 0) + { +

No workflow activity yet.

+ } + else + { +
    + @foreach (WorkflowLogEntry entry in Entries) + { +
  1. + @entry.Outcome: @entry.Message +
  2. + } +
+ } +
+ +@code { + [Parameter] + public IReadOnlyList Entries { get; set; } = []; +} diff --git a/BlogWriter.Web/Services/BlogWorkspaceService.cs b/BlogWriter.Web/Services/BlogWorkspaceService.cs index d5878f1..4b8561b 100644 --- a/BlogWriter.Web/Services/BlogWorkspaceService.cs +++ b/BlogWriter.Web/Services/BlogWorkspaceService.cs @@ -34,7 +34,12 @@ public Task SubmitInitialAsync() return RunSessionOperationAsync( prompt, range, - cancellationToken => _sessions.StartAsync(prompt, range.Min, range.Max, cancellationToken), + (cancellationToken, output) => _sessions.StartAsync( + prompt, + range.Min, + range.Max, + cancellationToken, + output), clearInput: () => State.InitialPrompt = ""); } @@ -56,12 +61,13 @@ public Task SubmitRevisionAsync() return RunSessionOperationAsync( revision, range, - cancellationToken => _sessions.ReviseAsync( + (cancellationToken, output) => _sessions.ReviseAsync( activeSession, revision, range.Min, range.Max, - cancellationToken), + cancellationToken, + output), clearInput: () => State.RevisionPrompt = ""); } @@ -103,6 +109,7 @@ public async Task ListAsync(bool discardConfirmed) State.SelectionInput = ""; State.ValidationMessage = null; State.StatusMessage = "Loading saved sessions..."; + State.AppendLog(State.StatusMessage, WorkflowOutputOutcome.Progress); NotifyChanged(); try @@ -112,6 +119,7 @@ public async Task ListAsync(bool discardConfirmed) State.StatusMessage = State.DisplayedSessions.Count == 0 ? "No saved sessions are available." : $"{State.DisplayedSessions.Count} saved sessions loaded."; + State.AppendLog(State.StatusMessage, WorkflowOutputOutcome.Success); } catch (Exception exception) when (exception is not OperationCanceledException) { @@ -161,6 +169,7 @@ public async Task QuitAsync(bool discardConfirmed) await CancelActiveOperationAsync(); ClearWorkspace(WorkspaceMode.Ended); State.StatusMessage = "This Blog Writer session has ended."; + State.AppendLog(State.StatusMessage, WorkflowOutputOutcome.Success); NotifyChanged(); return WorkspaceTransitionResult.Completed; } @@ -182,7 +191,7 @@ public void Dispose() private async Task RunSessionOperationAsync( string input, WordRange submittedRange, - Func> operation, + Func, Task> operation, Action clearInput) { if (State.Mode == WorkspaceMode.Ended) @@ -209,9 +218,11 @@ private async Task RunSessionOperationAsync( State.IsProcessing = true; State.ValidationMessage = null; State.StatusMessage = "Writing in progress..."; + State.AppendLog(State.StatusMessage, WorkflowOutputOutcome.Progress); NotifyChanged(); - Task task = operation(cancellation.Token); + var output = new Progress(update => HandleOutput(version, update)); + Task task = operation(cancellation.Token, output); _activeOperation = task; try { @@ -224,12 +235,14 @@ private async Task RunSessionOperationAsync( clearInput(); Publish(session, submittedRange); State.StatusMessage = "Writing complete."; + State.AppendLog(State.StatusMessage, WorkflowOutputOutcome.Success); } catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { if (version == _operationVersion) { State.StatusMessage = "Writing cancelled."; + State.AppendLog(State.StatusMessage, WorkflowOutputOutcome.Cancellation); } } catch (Exception exception) @@ -239,6 +252,11 @@ private async Task RunSessionOperationAsync( State.ValidationMessage = exception is SessionConflictException ? "This session changed elsewhere. Refresh the list before retrying." : "The writing operation failed. Your previous draft is unchanged."; + State.AppendLog( + State.ValidationMessage, + exception is SessionConflictException + ? WorkflowOutputOutcome.Conflict + : WorkflowOutputOutcome.Failure); State.StatusMessage = null; } } @@ -319,7 +337,21 @@ private void Publish(BlogSession session, WordRange? submittedRange = null) State.ActiveSession = session; State.Draft = session.State.Draft; - State.Review = session.State.ReviewNotes; + if (submittedRange is null) + { + State.Review = session.State.ReviewNotes; + State.ReviewerUpdateKeys.Clear(); + if (!string.IsNullOrWhiteSpace(State.Review)) + { + State.ReviewerUpdateKeys.Add($"loaded-{session.Id}"); + } + State.ClearOutput(); + } + else if (!string.IsNullOrWhiteSpace(session.State.ReviewNotes) && + !State.Review.Contains(session.State.ReviewNotes, StringComparison.Ordinal)) + { + State.AppendReviewerFeedback(session.State.ReviewNotes, $"final-{session.Id}-{_operationVersion}"); + } State.DisplayedSessions = []; State.SelectionInput = ""; State.Mode = WorkspaceMode.Draft; @@ -335,6 +367,7 @@ private void ClearWorkspace(WorkspaceMode mode) SetAcceptedAndVisibleRange(WordRange.Default); State.Draft = ""; State.Review = ""; + State.ClearOutput(); State.ActiveSession = null; State.DisplayedSessions = []; State.SelectionInput = ""; @@ -347,6 +380,26 @@ private void ClearWorkspace(WorkspaceMode mode) private void SetValidation(string message) { State.ValidationMessage = message; + State.AppendLog(message, WorkflowOutputOutcome.Validation); + NotifyChanged(); + } + + private void HandleOutput(long version, WorkflowOutputUpdate update) + { + if (version != _operationVersion || State.Mode == WorkspaceMode.Ended) + { + return; + } + + if (update.Kind == WorkflowOutputKind.ReviewerFeedback) + { + State.AppendReviewerFeedback(update.Message, update.UpdateKey); + } + else + { + State.AppendLog(update.Message, update.Outcome); + } + NotifyChanged(); } @@ -357,6 +410,7 @@ private bool TryCaptureRange(out WordRange range) { range = default; State.ValidationMessage = "Correct Min and Max before submitting."; + State.AppendLog(State.ValidationMessage, WorkflowOutputOutcome.Validation); NotifyChanged(); return false; } diff --git a/BlogWriter.Web/Services/BlogWorkspaceState.cs b/BlogWriter.Web/Services/BlogWorkspaceState.cs index 889f05c..593cd22 100644 --- a/BlogWriter.Web/Services/BlogWorkspaceState.cs +++ b/BlogWriter.Web/Services/BlogWorkspaceState.cs @@ -1,5 +1,7 @@ namespace BlogWriter.Web.Services; +public sealed record WorkflowLogEntry(string Message, WorkflowOutputOutcome Outcome); + public enum WorkspaceMode { New, @@ -32,6 +34,43 @@ public sealed class BlogWorkspaceState public bool IsProcessing { get; internal set; } public string? StatusMessage { get; internal set; } public string? ValidationMessage { get; internal set; } + public IReadOnlyList WorkflowLog { get; internal set; } = []; + + internal HashSet ReviewerUpdateKeys { get; } = new(StringComparer.Ordinal); + + internal void AppendLog(string message, WorkflowOutputOutcome outcome) + { + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + WorkflowLog = [.. WorkflowLog, new WorkflowLogEntry(message, outcome)]; + } + + internal void ClearOutput() + { + WorkflowLog = []; + ReviewerUpdateKeys.Clear(); + } + + internal void AppendReviewerFeedback(string message, string updateKey) + { + if (string.IsNullOrWhiteSpace(message) || !ReviewerUpdateKeys.Add(updateKey)) + { + return; + } + + if (string.Equals(Review, message, StringComparison.Ordinal) || + Review.EndsWith($"\n\n{message}", StringComparison.Ordinal)) + { + return; + } + + Review = string.IsNullOrWhiteSpace(Review) + ? message + : $"{Review}\n\n{message}"; + } public bool IsSelectionVisible => Mode == WorkspaceMode.List && DisplayedSessions.Count > 0; public bool IsReviseEnabled => IsSelectionVisible && !IsProcessing; diff --git a/BlogWriter.Web/wwwroot/app.css b/BlogWriter.Web/wwwroot/app.css index 48e679d..8ae7bcb 100644 --- a/BlogWriter.Web/wwwroot/app.css +++ b/BlogWriter.Web/wwwroot/app.css @@ -135,7 +135,7 @@ button, a { touch-action: manipulation; } .session-entry strong, .session-entry time { display: block; } .session-entry time { margin-top: 5px; color: var(--muted); font-size: .7rem; } -.workspace-footer { display: flex; align-items: end; gap: 16px; justify-content: space-between; } +.workspace-footer { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 12px 16px; } .command-bar { display: grid; grid-template-columns: repeat(4, 82px); gap: 8px; } .command-bar button, .dialog-actions button, .command-button { min-height: 36px; @@ -151,12 +151,14 @@ button, a { touch-action: manipulation; } .command-bar button:disabled { border-color: var(--line); color: #8a918c; cursor: not-allowed; } .danger-command { border-color: var(--coral) !important; color: #8b2b20 !important; } -.session-selector { width: 150px; display: grid; gap: 5px; } +.session-selector { width: 150px; grid-column: 2; grid-row: 1 / span 2; display: grid; align-self: end; gap: 5px; } .session-selector input { width: 100%; min-height: 36px; border: 1px solid var(--ink); padding: 6px 9px; background: white; } -.status-stack { min-height: 26px; } -.status-message, .validation-message { margin: 0; font-size: .78rem; } -.status-message { color: var(--forest-dark); } -.validation-message { color: #9d2c20; } +.workflow-log { grid-column: 1; min-width: 0; max-height: 8rem; overflow: auto; border-top: 1px solid var(--line); padding: 8px 0 0; } +.workflow-log-list { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; } +.workflow-log-entry { margin: 0; font-size: .78rem; line-height: 1.35; overflow-wrap: anywhere; } +.workflow-log-progress { color: var(--muted); } +.workflow-log-success { color: var(--forest-dark); } +.workflow-log-cancellation, .workflow-log-validation, .workflow-log-conflict, .workflow-log-failure { color: #9d2c20; } .dialog-backdrop { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 18px; background: rgba(20, 24, 21, .58); } .confirm-dialog { width: min(430px, calc(100% - 36px)); border: 1px solid var(--ink); padding: 22px; background: var(--surface); box-shadow: 9px 9px 0 var(--ink); } @@ -180,8 +182,9 @@ button, a { touch-action: manipulation; } .work-pane + .work-pane { border-left: 0; border-top: 1px solid var(--ink); } .work-grid { min-height: 0; } .pane-content { max-height: 38vh; } - .workspace-footer { align-items: stretch; flex-direction: column; } + .workspace-footer { display: flex; align-items: stretch; flex-direction: column; } .command-bar { width: 100%; grid-template-columns: repeat(4, minmax(0, 1fr)); } + .workflow-log { max-height: 10rem; } .session-selector { width: 100%; } } diff --git a/BlogWriterSessionService.cs b/BlogWriterSessionService.cs index 719b18d..3962517 100644 --- a/BlogWriterSessionService.cs +++ b/BlogWriterSessionService.cs @@ -11,7 +11,8 @@ public async Task StartAsync( string prompt, int minWords = ResearchState.DefaultMinWords, int maxWords = ResearchState.DefaultMaxWords, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + IProgress? output = null) { ArgumentException.ThrowIfNullOrWhiteSpace(prompt); WordRange range = WordRange.Create(minWords, maxWords); @@ -23,7 +24,7 @@ public async Task StartAsync( MaxWords = range.Max, }, cancellationToken); - session.State = await _workflow.RunAsync(session.State, cancellationToken); + session.State = await _workflow.RunAsync(session.State, cancellationToken, output); await _sessionStore.SaveAsync(session, cancellationToken); return session; } @@ -33,7 +34,8 @@ public async Task ReviseAsync( string revision, int minWords, int maxWords, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + IProgress? output = null) { ArgumentNullException.ThrowIfNull(session); ArgumentException.ThrowIfNullOrWhiteSpace(revision); @@ -52,7 +54,7 @@ public async Task ReviseAsync( candidate.State.MinWords = range.Min; candidate.State.MaxWords = range.Max; candidate.State.StartFollowUp(revision); - candidate.State = await _workflow.RunAsync(candidate.State, cancellationToken); + candidate.State = await _workflow.RunAsync(candidate.State, cancellationToken, output); await _sessionStore.SaveAsync(candidate, cancellationToken); return candidate; } diff --git a/IBlogWorkflow.cs b/IBlogWorkflow.cs index c8433ac..f13f394 100644 --- a/IBlogWorkflow.cs +++ b/IBlogWorkflow.cs @@ -2,5 +2,8 @@ namespace BlogWriter; public interface IBlogWorkflow { - Task RunAsync(ResearchState state, CancellationToken cancellationToken = default); + Task RunAsync( + ResearchState state, + CancellationToken cancellationToken = default, + IProgress? output = null); } diff --git a/IBlogWriterSessionService.cs b/IBlogWriterSessionService.cs index fae0997..c9ea78e 100644 --- a/IBlogWriterSessionService.cs +++ b/IBlogWriterSessionService.cs @@ -6,14 +6,16 @@ Task StartAsync( string prompt, int minWords = ResearchState.DefaultMinWords, int maxWords = ResearchState.DefaultMaxWords, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + IProgress? output = null); Task ReviseAsync( BlogSession session, string revision, int minWords, int maxWords, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + IProgress? output = null); Task> ListAsync(CancellationToken cancellationToken = default); diff --git a/README.md b/README.md index f099a86..4baf805 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,11 @@ the same workflow and Cosmos session store. It includes separate draft and revie panes, prompt and revision inputs, numbered saved-session recall, bounded cancellation, and responsive WCAG 2.2 AA-oriented controls. The compact `Min` and `Max` fields between the prompts and content panes set the target word range for new drafts and revisions; -they default to 1000 and 2000 words. +they default to 1000 and 2000 words. Workflow progress, validation, cancellation, and +failure messages appear as an ordered log beneath the New/List/Revise/Quit buttons. +Reviewer feedback is kept in Reviewer notes as it arrives and accumulates across +revisions for the active session; it is cleared when starting New or loading another +session. After configuring Microsoft Entra, Foundry, and Cosmos values from [docs/configuration.md](docs/configuration.md), start it with: diff --git a/WorkflowOutputPublisher.cs b/WorkflowOutputPublisher.cs new file mode 100644 index 0000000..1dffbd9 --- /dev/null +++ b/WorkflowOutputPublisher.cs @@ -0,0 +1,55 @@ +namespace BlogWriter; + +internal sealed class WorkflowOutputPublisher +{ + private readonly IProgress? _output; + private readonly long _operationVersion; + private long _sequence; + + public WorkflowOutputPublisher(IProgress? output, long operationVersion) + { + _output = output; + _operationVersion = operationVersion; + } + + public void PublishLifecycle(WorkflowOutputOutcome outcome, string message) + { + Publish(WorkflowOutputKind.Lifecycle, outcome, message, revisionNumber: null); + } + + public void PublishReviewer(string message, int revisionNumber) + { + Publish(WorkflowOutputKind.ReviewerFeedback, WorkflowOutputOutcome.Review, message, revisionNumber); + } + + private void Publish( + WorkflowOutputKind kind, + WorkflowOutputOutcome outcome, + string message, + int? revisionNumber) + { + if (_output is null || string.IsNullOrWhiteSpace(message)) + { + return; + } + + long sequence = ++_sequence; + WorkflowOutputUpdate update = WorkflowOutputUpdate.Create( + kind, + outcome, + message, + _operationVersion, + sequence, + $"workflow-{_operationVersion}-{sequence}", + revisionNumber); + + try + { + _output.Report(update); + } + catch + { + // Output is observational; a UI observer must not change workflow semantics. + } + } +} diff --git a/WorkflowOutputUpdate.cs b/WorkflowOutputUpdate.cs new file mode 100644 index 0000000..6738928 --- /dev/null +++ b/WorkflowOutputUpdate.cs @@ -0,0 +1,71 @@ +namespace BlogWriter; + +public enum WorkflowOutputKind +{ + Lifecycle, + ReviewerFeedback, +} + +public enum WorkflowOutputOutcome +{ + Progress, + Success, + Cancellation, + Validation, + Conflict, + Failure, + Review, +} + +public sealed record WorkflowOutputUpdate +{ + private WorkflowOutputUpdate( + WorkflowOutputKind kind, + WorkflowOutputOutcome outcome, + string message, + long operationVersion, + long sequence, + string updateKey, + int? revisionNumber) + { + Kind = kind; + Outcome = outcome; + Message = message; + OperationVersion = operationVersion; + Sequence = sequence; + UpdateKey = updateKey; + RevisionNumber = revisionNumber; + } + + public WorkflowOutputKind Kind { get; } + public WorkflowOutputOutcome Outcome { get; } + public string Message { get; } + public long OperationVersion { get; } + public long Sequence { get; } + public string UpdateKey { get; } + public int? RevisionNumber { get; } + + public static WorkflowOutputUpdate Create( + WorkflowOutputKind kind, + WorkflowOutputOutcome outcome, + string message, + long operationVersion, + long sequence, + string updateKey, + int? revisionNumber = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); + ArgumentException.ThrowIfNullOrWhiteSpace(updateKey); + ArgumentOutOfRangeException.ThrowIfNegative(operationVersion); + ArgumentOutOfRangeException.ThrowIfNegative(sequence); + + return new WorkflowOutputUpdate( + kind, + outcome, + message, + operationVersion, + sequence, + updateKey, + revisionNumber); + } +} \ No newline at end of file diff --git a/Workflows/BlogExecutors.cs b/Workflows/BlogExecutors.cs index 7f91764..84f593c 100644 --- a/Workflows/BlogExecutors.cs +++ b/Workflows/BlogExecutors.cs @@ -34,13 +34,20 @@ private async ValueTask HandleAsync(ResearchState state, IWorkflo /// Reviews the draft and records approval / revision notes. Acts as the terminal /// output node: when no further revision is needed it yields the final state. /// -internal sealed partial class ReviewerExecutor(IReviewerAgent reviewer) : Executor("Reviewer") +internal sealed partial class ReviewerExecutor( + IReviewerAgent reviewer, + WorkflowOutputPublisher? publisher) : Executor("Reviewer") { [MessageHandler] private async ValueTask HandleAsync(ResearchState state, IWorkflowContext context, CancellationToken cancellationToken) { state = await reviewer.ReviewerNodeAsync(state, cancellationToken); + if (!string.IsNullOrWhiteSpace(state.ReviewNotes)) + { + publisher?.PublishReviewer(state.ReviewNotes, state.RevisionNumber); + } + if (!state.NeedsRevision) { // Approved, or the revision cap was hit — emit the final result. diff --git a/docs/configuration.md b/docs/configuration.md index 920571e..47d2a32 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -91,6 +91,15 @@ keys: loading a saved session restores its range, and a successful revision pers the updated range. Unsaved range changes participate in the existing New/List/Quit discard confirmation. +### Workflow log and Reviewer notes + +The web workspace places an ordered workflow log directly beneath the command buttons. +It contains progress, success, validation, cancellation, conflict, and failure output; +the separate status/validation stack is not used. Reviewer feedback is routed to the +Reviewer notes pane as it becomes available and is retained across revisions for the +active session. New and loading a different saved session clear transient log history +and reviewer history; incremental output is not persisted as a separate record. + ## Keeping prompts in sync Each hosted agent's `AgentPrompt.cs` must be kept in sync with the corresponding section diff --git a/specs/005-workflow-log-reviewer-notes/checklists/requirements.md b/specs/005-workflow-log-reviewer-notes/checklists/requirements.md new file mode 100644 index 0000000..9d3017c --- /dev/null +++ b/specs/005-workflow-log-reviewer-notes/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Workflow Log and Reviewer Notes + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-09-20 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- The existing command bar and Reviewer notes pane are referenced as user-facing workspace concepts. +- Incremental reviewer feedback is specified as visible workspace behavior, without prescribing transport or framework details. +- The workflow log intentionally replaces the existing separate status/validation presentation beneath the command bar. diff --git a/specs/005-workflow-log-reviewer-notes/contracts/workflow-output.md b/specs/005-workflow-log-reviewer-notes/contracts/workflow-output.md new file mode 100644 index 0000000..276ee78 --- /dev/null +++ b/specs/005-workflow-log-reviewer-notes/contracts/workflow-output.md @@ -0,0 +1,46 @@ +# Workflow Output Contract + +This contract describes the application-to-workspace output boundary for feature 005. It is an in-process contract; no public HTTP endpoint or persisted document shape is added. + +## Observer Contract + +The workflow/session boundary MAY receive an optional output observer for one operation. The observer receives typed `WorkflowOutputUpdate` values and does not control workflow routing, cancellation, persistence, or retry behavior. + +Required behavior: + +- The observer is invoked for lifecycle events that are meaningful to the workspace: operation started, executor progress, completion, success, cancellation, validation, conflict, and failure. +- Reviewer output is emitted as `ReviewerFeedback` updates, not as generic lifecycle log messages. +- Updates include operation identity and sequence information so the workspace can reject stale and duplicate delivery. +- Observer failures MUST NOT change workflow success/failure semantics or create an unbounded retry loop. +- Cancellation continues to use the existing cancellation token. + +## Destination Rules + +| Update kind | Destination | Required behavior | +| --- | --- | --- | +| Lifecycle | Workflow log beneath command bar | Append in chronological order with outcome semantics | +| ReviewerFeedback | Reviewer notes pane | Append in arrival order and retain across revisions for the active session | +| Validation | Workflow log | Append before any workflow call when submission input is invalid | +| Failure/Conflict | Workflow log | Append outcome and preserve already received reviewer notes | + +## Workspace Reset Rules + +- New accepted: clear workflow log and Reviewer notes. +- Different saved session loaded: replace transient output and seed Reviewer notes from the loaded session's stored final review text. +- Revision in the same active session: preserve existing Reviewer notes and append new feedback. +- Superseded operation: ignore all late updates from the old operation. +- Ended workspace: ignore late updates and preserve the ended-state output rules. + +## Accessibility Rules + +- The workflow log has a programmatic label and polite live-update semantics. +- Reviewer notes has a programmatic label and polite live-update semantics for appended content. +- Updates do not move keyboard focus. +- Long content is text-safe, readable, and scrollable without horizontal overflow at 390 × 844 and 1440 × 900. + +## Compatibility Rules + +- Existing command callbacks and session ownership remain unchanged. +- Existing final `BlogSession.State.ReviewNotes` remains authoritative when loading a saved session. +- Existing word-count, cancellation, conflict, and bounded revision behavior remains unchanged. +- No new hosted agent, model call, credential, token budget, Cosmos property, or deployment resource is required. diff --git a/specs/005-workflow-log-reviewer-notes/data-model.md b/specs/005-workflow-log-reviewer-notes/data-model.md new file mode 100644 index 0000000..64af6df --- /dev/null +++ b/specs/005-workflow-log-reviewer-notes/data-model.md @@ -0,0 +1,77 @@ +# Data Model: Workflow Log and Reviewer Notes + +No persisted schema change is required. Workflow log history and incremental reviewer feedback are transient workspace output. A loaded saved session seeds Reviewer notes from the existing persisted `ResearchState.ReviewNotes` value. + +## Workflow Output Update + +A typed update emitted by the workflow/session boundary. + +| Field | Type | Rules | +| --- | --- | --- | +| Kind | Lifecycle or ReviewerFeedback | Determines the destination surface | +| OperationVersion | Long integer | Identifies the workspace operation that produced the update | +| Sequence | Long integer | Monotonically increasing within one operation; preserves arrival order | +| Message | String | Required user-visible text; rendered as text, never markup | +| Outcome | Progress, Success, Cancellation, Validation, Conflict, or Failure | Required for lifecycle entries; optional/Review for reviewer updates | +| RevisionNumber | Integer or null | Set for reviewer feedback associated with a workflow revision | +| UpdateKey | String | Stable identity used to ignore duplicate delivery | + +## Workflow Log Entry + +A lifecycle-oriented projection of a `WorkflowOutputUpdate`. + +| Field | Type | Rules | +| --- | --- | --- | +| Message | String | Preserved exactly as user-visible text | +| Outcome | Log outcome | Controls styling and accessible status semantics | +| Sequence | Long integer | Ordered ascending | +| OperationVersion | Long integer | Must match the current workspace operation before append | + +The log contains progress, success, cancellation, validation, conflict, and failure messages. It is not persisted with the saved blog session. + +## Reviewer Feedback Update + +A reviewer-oriented projection of a `WorkflowOutputUpdate`. + +| Field | Type | Rules | +| --- | --- | --- | +| Message | String | Reviewer output rendered as text | +| RevisionNumber | Integer | Identifies the review pass when available | +| Sequence | Long integer | Ordered by arrival within the active workspace session | +| UpdateKey | String | Duplicate updates with the same key are ignored | + +## Workspace Output State + +Circuit-scoped state owned by `BlogWorkspaceState`. + +| Field | Type | Rules | +| --- | --- | --- | +| WorkflowLog | Ordered read-only collection | Appends current-operation lifecycle entries; cleared/replaced with workspace transitions | +| ReviewerNotes | Ordered read-only collection | Appends unseen feedback across revisions; seeded from a loaded session's final review | +| CurrentOperationVersion | Long integer | Existing workspace version; rejects late updates | +| IsProcessing | Boolean | Existing processing state; output updates do not change command semantics | + +## State Transitions + +| From | Event | Result | +| --- | --- | --- | +| New workspace | Initialize | Empty workflow log and Reviewer notes empty state | +| Any active mode | Initial submission starts | Append progress/start entry; begin accepting updates for a new operation version | +| Processing | Lifecycle event | Append one ordered log entry if operation version is current | +| Processing | Reviewer feedback update | Append unseen update to Reviewer notes if operation version is current | +| Processing | Success | Append success entry; publish final draft; retain accumulated Reviewer notes | +| Processing | Cancellation/failure | Append outcome entry; retain feedback already received | +| Active session | Revision starts | Preserve existing Reviewer notes; begin accepting new reviewer updates | +| Any active mode | New accepted | Clear log and Reviewer notes, then reset workspace | +| List mode | Different session loaded | Replace log according to existing load behavior and seed Reviewer notes from the loaded session's persisted review | +| Any active mode | Superseded/late update | Ignore update; do not mutate current output | +| Ended workspace | Any late update | Ignore update | + +## Validation Rules + +- Every update has a non-empty user-visible message and a destination kind. +- Only the current operation version may mutate workspace output. +- Reviewer updates with an already-seen `UpdateKey` are ignored. +- Log and Reviewer notes content is rendered as text. +- Collection changes notify the existing workspace event so the UI renders without a manual refresh. +- Transient output is not written to Cosmos and does not alter session ownership or ETag behavior. diff --git a/specs/005-workflow-log-reviewer-notes/plan.md b/specs/005-workflow-log-reviewer-notes/plan.md new file mode 100644 index 0000000..b09bfbb --- /dev/null +++ b/specs/005-workflow-log-reviewer-notes/plan.md @@ -0,0 +1,129 @@ +# Implementation Plan: Workflow Log and Reviewer Notes + +**Branch**: `005-workflow-log-reviewer-notes` | **Date**: 2026-09-20 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `/specs/005-workflow-log-reviewer-notes/spec.md` + +**Note**: This template is filled in by the `/speckit-plan` command; its definition describes the execution workflow. + +## Summary + +Replace the separate status/validation stack beneath the command bar with an ordered, +accessible workflow log, and route reviewer feedback updates into the existing Reviewer +notes pane as they arrive. Preserve existing workflow topology, session persistence, +cancellation, stale-result protection, and word-count behavior by carrying an optional +typed output observer through the existing workflow and session-service boundaries. + +The workspace will keep two distinct output collections: lifecycle/progress/error log +entries and reviewer feedback entries. Reviewer feedback accumulates across revisions +for the active workspace session, while New and loading a different saved session clear +the accumulated view. + +## Technical Context + +**Language/Version**: C# / .NET 10, Razor components, HTML, and CSS + +**Primary Dependencies**: Existing Interactive Server Blazor host, `IBlogWorkflow`, +`IBlogWriterSessionService`, `BlogWorkspaceService`, Microsoft Agent Framework workflow +events, xUnit, bUnit, and Playwright test infrastructure; no new runtime package + +**Storage**: No new persisted schema. Workflow log entries and accumulated reviewer +feedback are circuit/workspace output; the existing saved `ResearchState.ReviewNotes` +remains the source for a newly loaded session. + +**Testing**: Focused xUnit service/workflow tests, bUnit component/state tests, browser +viewport/accessibility checks, full core/web test suites, and MAF Doctor comparison + +**Target Platform**: Existing server-hosted responsive Blazor application at 390 × 844 +and 1440 × 900 acceptance viewports + +**Project Type**: Existing shared application assembly plus Interactive Server Blazor +web host and web test project + +**Performance Goals**: Publish each accepted workflow update in the next normal circuit +render; append in memory without extra persistence or model calls; do not move keyboard +focus for routine updates + +**Constraints**: Log beneath the existing command bar; no separate status stack; append +chronologically; reviewer feedback stays in Reviewer notes; safely render text; preserve +operation-version stale-result guards, cancellation, owner isolation, bounded MAF +workflow topology, token budget, authentication, word-count controls, and WCAG 2.2 AA + +**Scale/Scope**: One in-memory log and reviewer-feedback history per interactive +workspace circuit; current operation and active session only; no server-side log +retention, export, cross-session aggregation, or new hosted agent + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +**Pre-design gate: PASS. Post-design gate: PASS.** + +| Principle | Plan response | Status | +| --- | --- | --- | +| Hosted-Agent Boundaries | Reviewer output is observed at the existing workflow/session boundary; no hosted-agent deployment or raw HTTP changes. | Pass | +| MAF-Native Workflow Composition | Existing workflow edges and bounded revision loop remain unchanged; lifecycle and reviewer updates are observations of existing execution. | Pass | +| Identity, Secrets, and Budget Control | No credential, secret, or token-budget changes; output observation does not add model calls or retries. | Pass | +| Testable and Observable Behavior | Typed output updates are injected through existing interfaces and covered with deterministic workflow, service, component, and browser tests. | Pass | +| Simple, Compatible Evolution | No persisted schema change; existing final session contract remains authoritative for load/save, with optional output reporting for live UI state. | Pass | + +MAF Doctor baseline is **F** with 4 existing errors, 3 warnings, 0 silent-starvation +risks, and 6 heuristic uncapped-call matches. The feature must not add findings or +change workflow topology. The existing baseline findings are outside this feature's +scope. + +## Project Structure + +### Documentation (this feature) + +```text +specs/005-workflow-log-reviewer-notes/ +├── plan.md # This file (/speckit-plan command output) +├── research.md # Phase 0 output (/speckit-plan command) +├── data-model.md # Phase 1 output (/speckit-plan command) +├── quickstart.md # Phase 1 output (/speckit-plan command) +├── contracts/ # Phase 1 output (/speckit-plan command) +└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan) +``` + +### Source Code (repository root) + +```text +./ +├── WorkflowOutputUpdate.cs # Typed lifecycle/reviewer update model +├── IBlogWorkflow.cs # Optional output observer contract +├── BlogWorkflow.cs # Publish workflow and reviewer updates +├── IBlogWriterSessionService.cs # Forward output observer to workflow +├── BlogWriterSessionService.cs # Preserve observer through start/revise +├── Workflows/BlogExecutors.cs # Identify reviewer output boundaries +├── BlogWriter.Web/ +│ ├── Components/Pages/Home.razor # Render command bar and log region +│ ├── Components/WorkflowLog.razor # Accessible chronological log +│ ├── Components/ReviewPane.razor # Render accumulated reviewer notes +│ ├── Services/BlogWorkspaceState.cs # Log and feedback collections +│ ├── Services/BlogWorkspaceService.cs # Route, reset, deduplicate, and publish updates +│ └── wwwroot/app.css # Responsive output surfaces +├── BlogWriter.Tests/ +│ ├── BlogWorkflowTests.cs +│ └── BlogWriterSessionServiceTests.cs +└── BlogWriter.Web.Tests/ + ├── BlogWorkspaceServiceTests.cs + ├── WorkflowLogTests.cs + ├── ReviewPaneTests.cs + ├── HomePageTests.cs + └── WorkspaceBrowserTests.cs +``` + +**Structure Decision**: Keep the update model and observer boundary in the shared +application assembly so workflow, session service, and web workspace use one contract. +Keep aggregation and reset behavior in the circuit-scoped `BlogWorkspaceService`; keep +rendering in focused Razor components. Do not persist transient log history or revise +the saved session schema. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| None | N/A | The feature fits existing workflow, session-service, workspace-state, component, and test boundaries. | diff --git a/specs/005-workflow-log-reviewer-notes/quickstart.md b/specs/005-workflow-log-reviewer-notes/quickstart.md new file mode 100644 index 0000000..539d084 --- /dev/null +++ b/specs/005-workflow-log-reviewer-notes/quickstart.md @@ -0,0 +1,70 @@ +# Quickstart: Workflow Log and Reviewer Notes + +## Prerequisites + +- Existing BlogWriter solution and web test project +- .NET 10 SDK +- No live Foundry, Cosmos, or Entra dependency for focused tests +- Read [workflow-output.md](contracts/workflow-output.md) for update routing and reset rules + +## Focused automated validation + +From the repository root: + +```powershell +dotnet test BlogWriter.Tests/BlogWriter.Tests.csproj --filter "FullyQualifiedName~BlogWorkflowTests|FullyQualifiedName~BlogWriterSessionServiceTests" +dotnet test BlogWriter.Web.Tests/BlogWriter.Web.Tests.csproj --filter "FullyQualifiedName~BlogWorkspaceServiceTests|FullyQualifiedName~WorkflowLogTests|FullyQualifiedName~ReviewPaneTests|FullyQualifiedName~HomePageTests" +``` + +Expected outcome: lifecycle updates are ordered, reviewer updates are routed separately, +duplicates and stale updates are ignored, feedback accumulates across revisions, and +New/session-load resets behave as specified. + +Current validation checkpoint: 80 core tests and 49 web tests pass; both projects build +cleanly. The required viewport contract remains 390 × 844 and 1440 × 900. + +## Manual workspace validation + +1. Start the web application using its existing HTTPS launch profile and sign in. +2. Confirm the New, List, Revise, and Quit buttons are visible. +3. Confirm the workflow log is directly beneath the buttons and the former separate status/validation stack is absent. +4. Submit a prompt and observe progress entries appear in chronological order without moving focus. +5. Confirm reviewer feedback appears in Reviewer notes as soon as it becomes available and does not appear only in the workflow log. +6. Let the workflow complete and confirm a success entry is appended while earlier log entries and Reviewer notes remain visible. +7. Submit a revision and confirm new reviewer feedback is appended after the earlier active-session feedback. +8. Cancel or end an in-flight operation and confirm the outcome is logged while feedback already received remains visible. +9. Trigger validation, conflict, and failure paths and confirm each outcome is logged with distinguishable semantics. +10. Use New and load a different saved session; confirm old transient log and reviewer history do not leak into the new workspace state. +11. Deliver or simulate a late update from a superseded operation; confirm it does not change the current log or Reviewer notes. + +## Responsive and accessibility validation + +At 390 × 844 and 1440 × 900: + +- Command buttons, workflow log, Draft pane, and Reviewer notes do not overlap or create horizontal scrolling. +- Workflow log and Reviewer notes have programmatic labels and readable order. +- New entries are announced politely without moving keyboard focus. +- Long messages remain text-safe, readable, and scrollable. +- Agent-provided markup-like text is displayed as literal text. + +Run the existing browser/accessibility checks and verify zero new WCAG 2.2 Level A/AA violations. + +## Full regression + +```powershell +dotnet test BlogWriter.Tests/BlogWriter.Tests.csproj +dotnet test BlogWriter.Web.Tests/BlogWriter.Web.Tests.csproj +dotnet build BlogWriter.csproj +dotnet build BlogWriter.Web/BlogWriter.Web.csproj +git diff --check +``` + +The existing authentication, owner isolation, session persistence, cancellation timeout, +word-count controls, workflow termination, and hosted-agent boundaries must remain unchanged. + +## MAF Doctor comparison + +Run MAF Doctor before and after implementation. The post-implementation grade and finding +counts must match the recorded pre-feature baseline: F, 4 errors, 3 warnings, 0 +silent-starvation risks, and 6 heuristic uncapped-call matches. No new agent call, +prompt, credential, or topology finding is expected. diff --git a/specs/005-workflow-log-reviewer-notes/research.md b/specs/005-workflow-log-reviewer-notes/research.md new file mode 100644 index 0000000..19c0e71 --- /dev/null +++ b/specs/005-workflow-log-reviewer-notes/research.md @@ -0,0 +1,49 @@ +# Research: Workflow Log and Reviewer Notes + +## Observe existing workflow lifecycle without changing topology + +**Decision**: Add an optional typed output observer to the existing workflow/session-service path. Translate existing workflow executor lifecycle events into user-visible log updates, and publish reviewer-state updates at the reviewer executor boundary. + +**Rationale**: `BlogWorkflow` already consumes a streaming workflow run and sees executor invoked, completed, failed, and final-output events. `BlogWriterSessionService` is the existing boundary used by the web workspace, so forwarding an optional observer exposes live output without raw HTTP, a new agent, a second workflow, or a persistence change. + +**Alternatives considered**: Polling session storage would delay output and add unnecessary reads; writing directly to a browser channel from executors would violate the application/workflow boundary; replacing the workflow with a new streaming topology would increase risk and conflict with the constitution. + +## Separate lifecycle log entries from reviewer feedback + +**Decision**: Use a typed update that distinguishes workflow log events from reviewer feedback updates. The workspace appends lifecycle events to a chronological log collection and routes reviewer updates only to the Reviewer notes collection. + +**Rationale**: The requested surfaces have different meaning and retention rules. A typed discriminant prevents review text from being rendered as status output and makes unit tests deterministic. + +**Alternatives considered**: One untyped string stream would make routing and accessibility semantics ambiguous; duplicating reviewer content into both surfaces would violate the dedicated Reviewer notes requirement. + +## Accumulate reviewer notes across revisions in one active session + +**Decision**: Give each reviewer update an operation/revision identity and append only unseen updates to the circuit-scoped Reviewer notes state. Clear the accumulated view when New starts or a different saved session is loaded. Loading a saved session initializes the view from its persisted final `ReviewNotes`. + +**Rationale**: This matches the accepted clarification, preserves review history across revisions, avoids duplicate delivery from late or repeated events, and keeps persisted sessions backward-compatible because transient history is not added to the schema. + +**Alternatives considered**: Replacing the pane on every revision loses review history; persisting every incremental update changes the session schema and creates conflict/merge behavior that the feature does not require. + +## Preserve stale-operation protection at the workspace boundary + +**Decision**: Associate observer updates with the existing workspace operation version and ignore updates from superseded operations. The observer must not mutate the workspace after New, List, Quit, cancellation timeout, or a newer submission supersedes the operation. + +**Rationale**: `BlogWorkspaceService` already uses versioning to suppress late final results. Applying the same guard to incremental updates prevents old feedback or log entries from leaking into a new session. + +**Alternatives considered**: Cancelling alone is insufficient because a workflow may not stop within the configured timeout; clearing output without version checks still permits late events to repopulate it. + +## Keep rendering text-safe and accessible + +**Decision**: Render update content as text in a labeled, scrollable log region and Reviewer notes region. Use a polite live-update mechanism that announces new content without moving focus; preserve existing focusable pane content and keyboard order. + +**Rationale**: Agent output is untrusted text and may contain markup-like characters. Text rendering prevents interpretation, while polite announcements preserve the user's reading position during long workflows. + +**Alternatives considered**: Injecting HTML would create a content-safety risk; aggressive assertive announcements could interrupt typing and make repeated workflow updates hard to follow; replacing the whole pane makes assistive-technology context unstable. + +## Validation strategy + +**Decision**: Extend existing xUnit and bUnit tests, add focused workflow/session observer tests, and extend Playwright viewport/accessibility checks. Run the full core/web regression and compare MAF Doctor findings before and after. + +**Rationale**: The feature crosses workflow, session, circuit state, rendering, and responsive accessibility boundaries. Focused tests isolate routing and stale-update rules; browser checks cover placement and live surfaces; the MAF comparison protects the unchanged agent topology. + +**Alternatives considered**: Manual browser testing alone cannot reliably cover duplicate, cancellation, and stale-update paths; full end-to-end live-agent tests are too slow and nondeterministic for the core contract. diff --git a/specs/005-workflow-log-reviewer-notes/spec.md b/specs/005-workflow-log-reviewer-notes/spec.md new file mode 100644 index 0000000..0abba2c --- /dev/null +++ b/specs/005-workflow-log-reviewer-notes/spec.md @@ -0,0 +1,125 @@ +# Feature Specification: Workflow Log and Reviewer Notes + +**Feature Branch**: `005-workflow-log-reviewer-notes` + +**Created**: 2026-09-20 + +**Status**: Draft + +**Input**: User description: "Put the log output under the buttons, replacing what is there. Put all the reviewer feedback into the Reviewer notes as they become available." + +## Clarifications + +### Session 2026-09-20 + +- Q: When a revision produces new reviewer feedback, should Reviewer notes keep feedback from earlier reviews in the same active session as well as the new feedback? → A: Append feedback across revisions for the active session; clear it on New or when loading another session. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - See Workflow Log Output (Priority: P1) + +As a blog writer, I can see workflow log output directly beneath the workspace buttons so I know what the application is doing without searching elsewhere on the page. + +**Why this priority**: Visible progress and outcomes are the primary requested change and make long-running writing operations understandable. + +**Independent Test**: Start, complete, cancel, and fail writing operations while observing the area below the command buttons; verify that log entries appear there in chronological order and the existing status area is no longer rendered separately. + +**Acceptance Scenarios**: + +1. **Given** the workspace is open, **When** no operation has run yet, **Then** the log region is present beneath the command buttons with an appropriate empty state. +2. **Given** a writing operation starts, **When** progress or a state transition occurs, **Then** a new log entry appears beneath the buttons without removing earlier entries. +3. **Given** an operation completes, is cancelled, or fails, **When** its outcome is known, **Then** the outcome is appended to the log with wording that distinguishes the result. +4. **Given** the workspace has existing status or validation messages, **When** the feature is used, **Then** those messages are represented in the log region rather than displayed in a separate status stack. + +--- + +### User Story 2 - Follow Reviewer Feedback (Priority: P1) + +As a blog writer, I can see reviewer feedback in Reviewer notes as soon as each piece becomes available so I can follow the review without waiting for unrelated workflow output. + +**Why this priority**: Reviewer feedback is the most important result of the review stage and must remain discoverable in its dedicated pane. + +**Independent Test**: Provide multiple reviewer feedback updates during one workflow and verify that each update appears in Reviewer notes in arrival order, remains visible, and is not redirected to the workflow log. + +**Acceptance Scenarios**: + +1. **Given** reviewer feedback becomes available, **When** the workspace receives it, **Then** Reviewer notes displays it without requiring a page refresh or a later command. +2. **Given** multiple reviewer feedback updates arrive, **When** each update is received, **Then** Reviewer notes retains all updates in their arrival order. +3. **Given** a workflow log event and reviewer feedback arrive close together, **When** both are rendered, **Then** workflow events remain under the buttons and reviewer feedback remains in Reviewer notes. +4. **Given** no reviewer feedback is available, **When** the workspace is displayed, **Then** Reviewer notes shows its existing empty state. +5. **Given** an active session has reviewer feedback from an earlier review, **When** a revision produces more feedback, **Then** the new feedback is appended after the earlier feedback. + +--- + +### User Story 3 - Read the Output Accessibly (Priority: P2) + +As a blog writer using keyboard navigation or assistive technology, I can identify new log entries and reviewer feedback without losing context or having content unexpectedly replaced. + +**Why this priority**: Incremental output is useful only if users can perceive and navigate it reliably. + +**Independent Test**: Use keyboard navigation and an accessibility inspection while log entries and reviewer feedback are appended; verify clear labels, reading order, and announcements for new content. + +**Acceptance Scenarios**: + +1. **Given** new workflow log output is appended, **When** the user is viewing the workspace, **Then** the log region is identified as live status content without repeatedly stealing focus. +2. **Given** new reviewer feedback is appended, **When** the user is viewing Reviewer notes, **Then** the new feedback is announced or otherwise perceivable while existing notes remain navigable. +3. **Given** the log or Reviewer notes becomes long, **When** the user navigates within it, **Then** the content remains readable, scrollable, and does not overlap the command buttons or content panes. + +--- + +### Edge Cases + +- A workflow fails before producing reviewer feedback; the error appears in the log and Reviewer notes retains its current content. +- A workflow is cancelled while feedback is arriving; feedback already received remains visible and no later stale update replaces newer workspace state. +- A new session or loaded session begins; the log and Reviewer notes follow the workspace's existing reset and publication behavior without leaking output from the prior session. Reviewer notes clear when New starts or when another session is loaded. +- The same reviewer feedback update is delivered more than once; duplicate content is not rendered as an unintended replacement or loss of prior notes. +- The log contains long messages or special characters; entries remain readable and are presented as text rather than executable markup. +- The workspace is in list or ended mode; the log remains below the buttons when applicable and does not obscure session-list or ended-state content. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The workspace MUST render a labeled workflow log region directly beneath the New, List, Revise, and Quit buttons. +- **FR-002**: The workflow log region MUST replace the existing separate status and validation message stack in that location; lifecycle, progress, validation, cancellation, conflict, and failure messages MUST be represented as log entries. +- **FR-003**: Log entries MUST be appended in chronological order and MUST remain visible until the workspace's existing reset, session-load, or end behavior clears or replaces them. +- **FR-004**: Each log entry MUST communicate the event text and distinguish at least progress, success, cancellation, validation, and failure outcomes. +- **FR-005**: The workflow log MUST provide a clear empty state when no log entries are available. +- **FR-006**: The workspace MUST route every reviewer feedback update to Reviewer notes as it becomes available. +- **FR-007**: Reviewer notes MUST preserve all reviewer feedback updates received for the current active session, including feedback from earlier revisions, in arrival order without replacing earlier feedback with a later update. +- **FR-008**: Reviewer feedback MUST NOT be rendered only in the workflow log or discarded when workflow progress is also being logged. +- **FR-009**: Reviewer notes MUST retain already received feedback when a workflow fails or is cancelled, and MUST clear only when New starts or a different saved session is loaded. +- **FR-010**: New reviewer feedback MUST become visible without a manual page refresh or unrelated command. +- **FR-011**: The log and Reviewer notes MUST handle stale or late updates from a superseded operation without overwriting newer workspace state. +- **FR-012**: Log entries and Reviewer notes MUST present received content as safely rendered text and MUST NOT interpret content as executable markup. +- **FR-013**: The workflow log MUST expose an accessible label, readable chronological order, and live-update semantics that do not repeatedly move keyboard focus. +- **FR-014**: Reviewer notes MUST expose an accessible label, preserve keyboard-readable content order, and make appended feedback perceivable to assistive technology. +- **FR-015**: The log and Reviewer notes MUST remain readable and scrollable at the existing required mobile and desktop workspace sizes without overlap or horizontal scrolling. +- **FR-016**: Existing command behavior, session ownership, cancellation, workflow termination, and word-count controls MUST remain unchanged except where needed to publish their output through the new log or Reviewer notes surfaces. +- **FR-017**: The feature MUST include focused tests for log ordering and outcomes, reviewer-feedback accumulation, reset/publication behavior, stale updates, and accessibility semantics. + +### Key Entities *(include if feature involves data)* + +- **Workflow Log Entry**: A chronological user-visible event containing text and an outcome or severity category. +- **Reviewer Feedback Update**: A unit of reviewer output delivered during a workflow and appended to the current Reviewer notes. +- **Workspace Output Stream**: The current session's ordered collection of workflow log entries and reviewer feedback updates displayed in their respective regions. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In 100% of tested progress, success, cancellation, validation, conflict, and failure scenarios, the resulting user-visible message appears beneath the command buttons in the workflow log. +- **SC-002**: In 100% of tests with multiple reviewer feedback updates across initial writing and revisions, Reviewer notes displays every update in arrival order without losing earlier feedback. +- **SC-003**: New log entries and reviewer feedback become visible within one normal workspace update cycle, without a manual page refresh or unrelated user command. +- **SC-004**: Accessibility checks identify no new Level A or AA failures in labels, live-update behavior, keyboard order, text rendering, or responsive overflow. +- **SC-005**: At 390 × 844 and 1440 × 900 viewport sizes, the command buttons, workflow log, Draft pane, and Reviewer notes remain readable and non-overlapping. +- **SC-006**: In 100% of reset, load, cancellation, and stale-update tests, output from a superseded workspace state does not overwrite output belonging to the current state. + +## Assumptions + +- "The buttons" refers to the existing New, List, Revise, and Quit command bar. +- "The log output" includes the existing progress, status, validation, cancellation, conflict, and failure messages currently shown below the command bar. +- Reviewer notes remain the dedicated destination for reviewer feedback; the workflow log is for workflow and workspace events rather than review content. +- Reviewer feedback may arrive in multiple updates during one operation or across revisions, and each update should be retained for the active session until the user starts New or loads a different session. +- Existing authentication, session persistence, word-count controls, and hosted-agent boundaries are reused. +- No server-side log retention or cross-session export is required for this feature. diff --git a/specs/005-workflow-log-reviewer-notes/tasks.md b/specs/005-workflow-log-reviewer-notes/tasks.md new file mode 100644 index 0000000..a039a54 --- /dev/null +++ b/specs/005-workflow-log-reviewer-notes/tasks.md @@ -0,0 +1,197 @@ +# Tasks: Workflow Log and Reviewer Notes + +**Input**: Design documents from `specs/005-workflow-log-reviewer-notes/` + +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/workflow-output.md`, and `quickstart.md` + +**Tests**: Included because the feature specification requires focused workflow, service, component, stale-update, and accessibility tests. + +**Organization**: Tasks are grouped by user story so each story can be implemented and tested independently after the shared output foundation is complete. + +## Phase 1: Setup + +**Purpose**: Establish the feature test seams and preserve the existing project structure. + +- [X] T001 [P] Add feature-specific test doubles for typed workflow output observers in `BlogWriter.Tests/TestChatClients.cs` or a new `BlogWriter.Tests/WorkflowOutputTestDoubles.cs`, preserving existing test-client conventions. +- [X] T002 [P] Add web workspace output test helpers for ordered log entries, reviewer updates, and operation-version control in `BlogWriter.Web.Tests/BlogWorkspaceOutputTestHelpers.cs`. +- [X] T003 [P] Record the feature validation commands and expected output surfaces in `specs/005-workflow-log-reviewer-notes/quickstart.md` before implementation tests are added. + +--- + +## Phase 2: Foundational Output Contract + +**Purpose**: Create the shared typed observer boundary required by all user stories without changing workflow topology, persistence, authentication, or token-budget behavior. + +- [X] T004 [P] Define `WorkflowOutputKind`, `WorkflowOutputOutcome`, and immutable `WorkflowOutputUpdate` in `WorkflowOutputUpdate.cs`, requiring a non-empty text message, operation identity, sequence, update key, and optional revision number as specified in `specs/005-workflow-log-reviewer-notes/data-model.md`. +- [X] T005 [P] Extend the workflow contract in `IBlogWorkflow.cs` with an optional typed output observer that does not control routing, cancellation, persistence, or retries. +- [X] T006 [P] Extend the session boundary in `IBlogWriterSessionService.cs` so initial and revision operations can forward an optional output observer while preserving existing cancellation and word-count parameters. +- [X] T007 Add shared contract tests in `BlogWriter.Tests/WorkflowOutputUpdateTests.cs` proving lifecycle/reviewer kinds, outcome values, required message validation, stable update keys, and sequence metadata follow `specs/005-workflow-log-reviewer-notes/contracts/workflow-output.md`. +- [X] T008 Add observer-forwarding tests in `BlogWriter.Tests/BlogWriterSessionServiceTests.cs` proving StartAsync and ReviseAsync pass the same observer to the workflow, do not persist transient output, and preserve existing stable-session failure behavior. +- [X] T009 Update `BlogWorkflow.cs` to publish operation-start, executor-invoked, executor-completed, final-success, cancellation, and failure updates from the existing streaming event loop without adding workflow edges, model calls, retries, or unbounded observer work. +- [X] T010 Update `Workflows/BlogExecutors.cs` to publish reviewer feedback at each reviewer boundary with operation/revision identity and sequence metadata while preserving the existing bounded reviewer-to-author loop and final `ResearchState` output. +- [X] T011 Update `BlogWriterSessionService.cs` to forward the optional observer through StartAsync and ReviseAsync and preserve Cosmos save/ETag/owner behavior unchanged. +- [X] T012 Run the foundational core tests in `BlogWriter.Tests/WorkflowOutputUpdateTests.cs`, `BlogWriter.Tests/BlogWorkflowTests.cs`, and `BlogWriter.Tests/BlogWriterSessionServiceTests.cs`; verify observer failures do not alter workflow success/failure semantics. + +**Checkpoint**: Typed output updates are available to the web workspace, and the existing workflow/session behavior remains compatible. + +--- + +## Phase 3: User Story 1 - See Workflow Log Output (Priority: P1) MVP + +**Goal**: Replace the separate status/validation stack below the command bar with an ordered workflow log that shows lifecycle and outcome messages. + +**Independent Test**: Start, complete, cancel, validate, conflict, and fail operations; verify all messages appear chronologically beneath the command bar, earlier entries remain visible, and no separate status stack is rendered. + +### Tests for User Story 1 + +- [X] T013 [P] [US1] Add `BlogWorkspaceService` tests in `BlogWriter.Web.Tests/BlogWorkspaceServiceTests.cs` for start/progress/success/cancellation/validation/conflict/failure log entries, chronological order, empty state, and removal of the old separate status/validation representation. +- [X] T014 [P] [US1] Add component contract tests in `BlogWriter.Web.Tests/WorkflowLogTests.cs` for the labeled log region, outcome semantics, ordered text rendering, empty state, and safe rendering of markup-like messages. +- [X] T015 [P] [US1] Add page integration assertions in `BlogWriter.Web.Tests/HomePageTests.cs` proving the workflow log is directly beneath the command bar and before unrelated workspace output, with no duplicate status stack. + +### Implementation for User Story 1 + +- [X] T016 [US1] Add ordered workflow-log state and append/reset methods to `BlogWriter.Web/Services/BlogWorkspaceState.cs`, retaining operation-version guards and rendering messages as plain text. +- [X] T017 [US1] Update `BlogWriter.Web/Services/BlogWorkspaceService.cs` to subscribe each submission to the typed observer, route lifecycle/validation/failure/conflict/cancellation updates to the log, ignore stale updates, and notify the circuit after each accepted update. +- [X] T018 [US1] Create `BlogWriter.Web/Components/WorkflowLog.razor` with an accessible label, chronological entries, outcome semantics, empty state, polite live-update behavior, and text-safe rendering. +- [X] T019 [US1] Update `BlogWriter.Web/Components/Pages/Home.razor` to render `WorkflowLog` directly under `CommandBar` and remove the separate `status-stack` rendering while preserving New/List/Revise/Quit callbacks. +- [X] T020 [US1] Update `BlogWriter.Web/wwwroot/app.css` to size and scroll the workflow log beneath the command buttons without overlap or horizontal overflow at 390 × 844 and 1440 × 900. +- [X] T021 [US1] Run the US1 tests from `BlogWriter.Web.Tests/BlogWorkspaceServiceTests.cs`, `BlogWriter.Web.Tests/WorkflowLogTests.cs`, and `BlogWriter.Web.Tests/HomePageTests.cs`; verify the MVP independently before starting US2. + +**Checkpoint**: User Story 1 is independently demonstrable with lifecycle output below the buttons and no separate status stack. + +--- + +## Phase 4: User Story 2 - Follow Reviewer Feedback (Priority: P1) + +**Goal**: Route each reviewer feedback update into Reviewer notes immediately, preserve feedback across revisions, and clear it only for New or a different loaded session. + +**Independent Test**: Deliver multiple reviewer updates during an initial operation and a revision; verify all updates appear in Reviewer notes in arrival order, duplicate/stale updates are ignored, and reset/load behavior does not leak prior-session feedback. + +### Tests for User Story 2 + +- [X] T022 [P] [US2] Add workflow/session observer tests in `BlogWriter.Tests/BlogWorkflowTests.cs` and `BlogWriter.Tests/BlogWriterSessionServiceTests.cs` proving each reviewer boundary emits a reviewer update without changing final state or workflow termination. +- [X] T023 [P] [US2] Add workspace accumulation tests in `BlogWriter.Web.Tests/BlogWorkspaceServiceTests.cs` for initial feedback, multiple updates, feedback across revisions, duplicate update keys, failure/cancellation retention, New reset, and loaded-session seeding. +- [X] T024 [P] [US2] Add Reviewer notes component tests in `BlogWriter.Web.Tests/ReviewPaneTests.cs` for empty state, ordered accumulated content, live-update semantics, and literal rendering of markup-like reviewer text. + +### Implementation for User Story 2 + +- [X] T025 [US2] Add ordered reviewer-feedback state and deduplication by `UpdateKey` to `BlogWriter.Web/Services/BlogWorkspaceState.cs`, preserving active-session history across revisions. +- [X] T026 [US2] Update `BlogWriter.Web/Services/BlogWorkspaceService.cs` to route reviewer updates separately from lifecycle logs, append across revisions, retain received feedback on failure/cancellation, clear on New, and seed from persisted `ReviewNotes` when loading another session. +- [X] T027 [US2] Update `BlogWriter.Web/Components/ReviewPane.razor` to render accumulated reviewer feedback in arrival order, retain its existing heading and empty state, and expose polite accessible updates without moving focus. +- [X] T028 [US2] Update `BlogWriter.Web/Components/Pages/Home.razor` to bind Reviewer notes to accumulated workspace feedback while keeping workflow events exclusively in `WorkflowLog`. +- [X] T029 [US2] Run the US2 workflow, session, workspace, and Reviewer notes tests; verify feedback routing and cross-revision accumulation independently of browser layout tests. + +**Checkpoint**: User Stories 1 and 2 work together without mixing lifecycle log output and reviewer feedback. + +--- + +## Phase 5: User Story 3 - Read the Output Accessibly (Priority: P2) + +**Goal**: Make both live output surfaces readable, keyboard-safe, responsive, and perceivable without focus theft. + +**Independent Test**: Inspect keyboard order, live announcements, long text, markup-like content, and layout at both required viewports while updates are appended. + +### Tests for User Story 3 + +- [X] T030 [P] [US3] Extend `BlogWriter.Web.Tests/WorkflowLogTests.cs` and `BlogWriter.Web.Tests/ReviewPaneTests.cs` with accessible labels, polite live-region semantics, focus preservation, ordered content, and text-safety assertions. +- [X] T031 [P] [US3] Extend `BlogWriter.Web.Tests/HomePageTests.cs` with DOM order assertions for command bar, workflow log, Draft, and Reviewer notes plus list/ended-mode output behavior. +- [X] T032 [P] [US3] Extend `BlogWriter.Web.Tests/WorkspaceBrowserTests.cs` for 390 × 844 and 1440 × 900 output visibility, non-overlap, scrollability, no horizontal overflow, and zero new WCAG 2.2 Level A/AA violations. + +### Implementation for User Story 3 + +- [X] T033 [US3] Refine `BlogWriter.Web/Components/WorkflowLog.razor` and `BlogWriter.Web/Components/ReviewPane.razor` live-region labels, focus behavior, and long-content structure to satisfy the accessibility contract without duplicating announcements. +- [X] T034 [US3] Refine `BlogWriter.Web/wwwroot/app.css` for bounded output heights, readable wrapping, visible focus, mobile/desktop spacing, and non-overlapping command/content surfaces. +- [X] T035 [US3] Update `BlogWriter.Web/Components/Pages/Home.razor` for final reading order, list/ended-mode handling, and live output semantics while retaining existing command and word-count controls. +- [X] T036 [US3] Run the US3 component, page, browser, and accessibility tests and record the required viewport outcomes in `specs/005-workflow-log-reviewer-notes/quickstart.md`. + +**Checkpoint**: All three user stories satisfy the requested behavior and accessibility contract. + +--- + +## Phase 6: Polish and Cross-Cutting Validation + +**Purpose**: Confirm compatibility, documentation, regression safety, and unchanged MAF health. + +- [X] T037 [P] Update `README.md` and `docs/configuration.md` with the workflow log location, lifecycle outcome behavior, Reviewer notes accumulation/reset rules, and the fact that transient output is not persisted. +- [X] T038 [P] Add or update focused test documentation in `specs/005-workflow-log-reviewer-notes/quickstart.md` for duplicate, stale, cancellation, and loaded-session scenarios. +- [X] T039 Run the full core and web suites from `specs/005-workflow-log-reviewer-notes/quickstart.md` and build `BlogWriter.csproj` plus `BlogWriter.Web/BlogWriter.Web.csproj`. +- [X] T040 Run `git diff --check` and inspect changed files for accidental status-stack remnants, unsafe markup rendering, public contract incompatibilities, or unrelated changes. +- [X] T041 Run MAF Doctor against the repository and compare with the pre-feature baseline: F, 4 errors, 3 warnings, 0 silent-starvation risks, and 6 heuristic uncapped-call matches; investigate any new finding before completion. +- [X] T042 Run every manual scenario in `specs/005-workflow-log-reviewer-notes/quickstart.md`, including initial/revision accumulation, reset/load, stale updates, cancellation/failure, responsive layout, and accessibility checks. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: T001-T003 can start immediately and may run in parallel. +- **Foundational (Phase 2)**: T004-T012 depends on Setup and blocks all user stories; T004-T006 and T007 can proceed in parallel, then T008-T012 follow the shared contract. +- **User Story 1 (Phase 3)**: Depends on the foundational output observer; it is the MVP and should be completed before broad integration. +- **User Story 2 (Phase 4)**: Depends on the foundational observer and integrates with the US1 log routing, but its workflow/session tests can begin once Phase 2 is complete. +- **User Story 3 (Phase 5)**: Depends on the US1 and US2 surfaces being present; accessibility refinements and browser checks follow both output paths. +- **Polish (Phase 6)**: Depends on all selected stories and their focused tests. + +### User Story Dependencies + +- **User Story 1 (P1)**: Requires Phase 2; no dependency on US2 or US3. MVP candidate. +- **User Story 2 (P1)**: Requires Phase 2; integrates with the output observer and existing Reviewer notes surface, but is independently testable with service/component tests. +- **User Story 3 (P2)**: Requires US1 and US2 rendered surfaces to validate combined reading order and responsive behavior. + +### Parallel Opportunities + +- Phase 1: T001, T002, and T003 can run in parallel. +- Phase 2: T004, T005, T006, and T007 can run in parallel when touching separate files; T008-T012 follow the shared model. +- US1: T013-T015 can run in parallel; T018 and T020 can proceed in parallel after state/service contracts are established. +- US2: T022-T024 can run in parallel; T025 and T027 can proceed in parallel after the shared update model exists. +- US3: T030-T032 can run in parallel; T033-T035 can be split by component/style/page after the tests define expected semantics. +- Phase 6: T037, T038, and T040 can run in parallel; T039, T041, and T042 are final validation tasks. + +## Parallel Execution Examples + +### User Story 1 + +```text +Task T013: Add workspace lifecycle/outcome tests in BlogWriter.Web.Tests/BlogWorkspaceServiceTests.cs +Task T014: Add WorkflowLog component tests in BlogWriter.Web.Tests/WorkflowLogTests.cs +Task T015: Add Home page placement tests in BlogWriter.Web.Tests/HomePageTests.cs +``` + +### User Story 2 + +```text +Task T022: Add workflow/session reviewer observer tests in BlogWriter.Tests/ +Task T023: Add workspace accumulation/reset tests in BlogWriter.Web.Tests/BlogWorkspaceServiceTests.cs +Task T024: Add ReviewPane rendering tests in BlogWriter.Web.Tests/ReviewPaneTests.cs +``` + +### User Story 3 + +```text +Task T030: Add component accessibility tests in BlogWriter.Web.Tests/WorkflowLogTests.cs and ReviewPaneTests.cs +Task T031: Add page-order and mode tests in BlogWriter.Web.Tests/HomePageTests.cs +Task T032: Add viewport/accessibility browser tests in BlogWriter.Web.Tests/WorkspaceBrowserTests.cs +``` + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1 setup and Phase 2 foundational observer contract. +2. Complete Phase 3 User Story 1. +3. Run the US1 focused tests and verify the workflow log beneath the command bar. +4. Stop for an MVP demonstration before adding reviewer-history accumulation. + +### Incremental Delivery + +1. Add the typed observer foundation and preserve existing workflow/session behavior. +2. Deliver US1 as the visible workflow-log MVP. +3. Deliver US2 to route and accumulate reviewer feedback across revisions. +4. Deliver US3 for final accessibility and responsive behavior. +5. Complete Phase 6 regression, browser validation, and MAF baseline comparison. + +## Notes + +- Every implementation task names an exact file path and follows `- [ ] T### [P?] [US#?] description` format. +- Tests are written before implementation within each story and must fail for the new behavior before the corresponding implementation tasks are completed. +- No task changes hosted-agent deployment, credentials, token budgets, Cosmos schema, session ownership, or workflow edges.