diff --git a/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs b/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs index f2a0e823b..0f60414b8 100644 --- a/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs +++ b/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs @@ -28,6 +28,7 @@ public class AutomationProposalsController : AuthenticatedControllerBase private readonly IAutomationExecutorService _executorService; private readonly ISimilarDecisionService _similarDecisionService; private readonly BoardAuthorizationService _authorizationService; + private readonly IProposalConflictDetector _conflictDetector; private readonly ICardHistoryService _cardHistoryService; private readonly ISideEffectAnalyzer _sideEffectAnalyzer; @@ -36,6 +37,7 @@ public AutomationProposalsController( IAutomationExecutorService executorService, ISimilarDecisionService similarDecisionService, BoardAuthorizationService authorizationService, + IProposalConflictDetector conflictDetector, ICardHistoryService cardHistoryService, ISideEffectAnalyzer sideEffectAnalyzer, IUserContext userContext) : base(userContext) @@ -44,6 +46,7 @@ public AutomationProposalsController( _executorService = executorService; _similarDecisionService = similarDecisionService; _authorizationService = authorizationService; + _conflictDetector = conflictDetector; _cardHistoryService = cardHistoryService; _sideEffectAnalyzer = sideEffectAnalyzer; } @@ -270,6 +273,23 @@ public async Task DismissProposals( : result.ToErrorActionResult(); } + /// + /// Gets tone-classified conflict/warning/status rows for a proposal. + /// + [HttpGet("{id}/conflicts")] + public async Task GetProposalConflicts(Guid id, CancellationToken cancellationToken = default) + { + if (!TryGetCurrentUserId(out var callerUserId, out var errorResult)) + return errorResult!; + + var auth = await AuthorizeProposalAsync(id, callerUserId, requireWriteAccess: false, cancellationToken); + if (auth.ErrorResult is not null) + return auth.ErrorResult; + + var result = await _conflictDetector.DetectConflictsAsync(id, callerUserId, cancellationToken); + return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); + } + /// /// Gets the card history ledger for a proposal, showing all touches on affected cards. /// @@ -304,7 +324,6 @@ public async Task GetProposalSideEffects(Guid id, CancellationTok var result = await _sideEffectAnalyzer.AnalyzeAsync(id, cancellationToken); return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } - /// /// Gets a diff preview for a proposal showing what changes will be made. /// diff --git a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs index 9d24b8e02..149651cec 100644 --- a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs @@ -47,6 +47,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/Taskdeck.Application/DTOs/ConflictRowDto.cs b/backend/src/Taskdeck.Application/DTOs/ConflictRowDto.cs new file mode 100644 index 000000000..ac0bc2479 --- /dev/null +++ b/backend/src/Taskdeck.Application/DTOs/ConflictRowDto.cs @@ -0,0 +1,18 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.DTOs; + +/// +/// DTO for a single conflict/warning/status row returned by the conflict detector. +/// +public record ConflictRowDto( + ConflictTone Tone, + string Key, + string Value +) +{ + public static ConflictRowDto FromDomain(ConflictRow row) + { + return new ConflictRowDto(row.Tone, row.Key, row.Value); + } +} diff --git a/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs index f067b0348..21265ff34 100644 --- a/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/IAutomationProposalRepository.cs @@ -20,6 +20,7 @@ public interface IAutomationProposalRepository : IRepository string actionType, ProposalSourceType sourceType, CancellationToken cancellationToken = default); + Task> GetPendingByOperationTargetAsync(string targetType, string targetId, CancellationToken cancellationToken = default); Task> GetExpiredAsync(CancellationToken cancellationToken = default); /// diff --git a/backend/src/Taskdeck.Application/Interfaces/ICardCommentRepository.cs b/backend/src/Taskdeck.Application/Interfaces/ICardCommentRepository.cs index 8519d7b8d..71e851b78 100644 --- a/backend/src/Taskdeck.Application/Interfaces/ICardCommentRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/ICardCommentRepository.cs @@ -5,5 +5,6 @@ namespace Taskdeck.Application.Interfaces; public interface ICardCommentRepository : IRepository { Task> GetByCardIdAsync(Guid cardId, CancellationToken cancellationToken = default); + Task CountByCardIdAsync(Guid cardId, CancellationToken cancellationToken = default); Task GetByIdWithMentionsAsync(Guid id, CancellationToken cancellationToken = default); } diff --git a/backend/src/Taskdeck.Application/Services/IProposalConflictDetector.cs b/backend/src/Taskdeck.Application/Services/IProposalConflictDetector.cs new file mode 100644 index 000000000..6a20a70b9 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IProposalConflictDetector.cs @@ -0,0 +1,16 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; + +namespace Taskdeck.Application.Services; + +/// +/// Detects conflicts, warnings, and status signals for a proposal. +/// Returns a tone-classified list of rows for the review UI. +/// +public interface IProposalConflictDetector +{ + Task>> DetectConflictsAsync( + Guid proposalId, + Guid userId, + CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Services/ProposalConflictDetector.cs b/backend/src/Taskdeck.Application/Services/ProposalConflictDetector.cs new file mode 100644 index 000000000..7f32a6b12 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/ProposalConflictDetector.cs @@ -0,0 +1,540 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +/// +/// Analyzes a proposal and produces tone-classified conflict/warning/status rows +/// for the review UI (section IV: Conflicts and warnings). +/// +public class ProposalConflictDetector : IProposalConflictDetector +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IAuthorizationService _authorizationService; + + public ProposalConflictDetector( + IUnitOfWork unitOfWork, + IAuthorizationService authorizationService) + { + _unitOfWork = unitOfWork; + _authorizationService = authorizationService; + } + + public async Task>> DetectConflictsAsync( + Guid proposalId, + Guid userId, + CancellationToken cancellationToken = default) + { + var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(proposalId, cancellationToken); + if (proposal is null) + return Result.Failure>(ErrorCodes.NotFound, "Proposal not found"); + + // Authorization: board-scoped proposals require current board read access, + // even for the original proposal owner, matching controller-level read paths. + var authResult = await AuthorizeAccessAsync(proposal, userId, cancellationToken); + if (!authResult.IsSuccess) + return Result.Failure>(authResult.ErrorCode, authResult.ErrorMessage); + + var rows = new List(); + var flaggedCardIds = new HashSet(); + var flaggedColumnIds = new HashSet(); + + // Entity caches to avoid redundant DB lookups across sub-methods + var cardCache = new Dictionary(); + var columnCache = new Dictionary(); + var projectedColumnChanges = await GetProjectedColumnChangesAsync( + proposal, cardCache, cancellationToken); + + // Check each condition and collect rows + await CheckStaleDataAsync(proposal, rows, flaggedCardIds, cardCache, cancellationToken); + await CheckWipLimitAsync(proposal, rows, flaggedColumnIds, columnCache, + projectedColumnChanges, cancellationToken); + await CheckDuplicatePendingProposalsAsync(proposal, rows, cancellationToken); + CheckHighRiskOperations(proposal, rows); + await CheckOutboundWebhooksAsync(proposal, rows, cancellationToken); + await CheckActiveCommentsAsync(proposal, rows, cancellationToken); + CheckMultipleOperationsOnSameCard(proposal, rows); + + // If no warnings or info rows, emit an Ok row + if (rows.Count == 0) + { + rows.Add(new ConflictRow(ConflictTone.Ok, "status", "No conflicts detected")); + } + else + { + // Add positive signals when applicable + await AddPositiveSignalsAsync(proposal, rows, flaggedCardIds, flaggedColumnIds, + cardCache, columnCache, projectedColumnChanges, cancellationToken); + } + + // Sort: Warn first, then Info, then Ok + var sorted = rows + .OrderBy(r => r.Tone) + .ToList(); + + return Result.Success>( + sorted.Select(ConflictRowDto.FromDomain).ToList()); + } + + private async Task AuthorizeAccessAsync( + AutomationProposal proposal, + Guid userId, + CancellationToken cancellationToken) + { + if (proposal.BoardId.HasValue) + { + var canRead = await _authorizationService.CanReadBoardAsync(userId, proposal.BoardId.Value); + if (canRead.IsSuccess && canRead.Value) + return Result.Success(); + + return Result.Failure(ErrorCodes.Forbidden, "You do not have permission to view conflicts for this proposal"); + } + + return proposal.RequestedByUserId == userId + ? Result.Success() + : Result.Failure(ErrorCodes.Forbidden, "You do not have permission to view conflicts for this proposal"); + } + + /// + /// Warn: target card was modified since the proposal was generated. + /// Compares card's UpdatedAt against proposal's CreatedAt. + /// Skips create operations since those cards don't exist yet. + /// + private async Task CheckStaleDataAsync( + AutomationProposal proposal, + List rows, + HashSet flaggedCardIds, + Dictionary cardCache, + CancellationToken cancellationToken) + { + var cardTargetIds = GetDistinctCardTargetIds(proposal, includeCreate: false); + if (cardTargetIds.Count == 0) return; + + foreach (var cardId in cardTargetIds) + { + var card = await GetOrFetchCardAsync(cardId, cardCache, cancellationToken); + if (card is null) + { + flaggedCardIds.Add(cardId); + rows.Add(new ConflictRow( + ConflictTone.Warn, + "missing-target", + $"Target card {cardId} no longer exists")); + continue; + } + + if (card.UpdatedAt > proposal.CreatedAt) + { + flaggedCardIds.Add(cardId); + rows.Add(new ConflictRow( + ConflictTone.Warn, + "stale-data", + $"Card \"{card.Title}\" was modified after this proposal was generated")); + } + } + } + + /// + /// Warn: target column is at or above WIP limit. + /// Checks operations that move or create cards into a column. + /// + private async Task CheckWipLimitAsync( + AutomationProposal proposal, + List rows, + HashSet flaggedColumnIds, + Dictionary columnCache, + IReadOnlyDictionary projectedColumnChanges, + CancellationToken cancellationToken) + { + if (projectedColumnChanges.Count == 0) return; + + foreach (var (columnId, projection) in projectedColumnChanges) + { + var column = await GetOrFetchColumnAsync(columnId, columnCache, cancellationToken); + if (column is null) + { + if (projection.ReceivesCards) + { + flaggedColumnIds.Add(columnId); + rows.Add(new ConflictRow( + ConflictTone.Warn, + "missing-target-column", + $"Target column {columnId:N} no longer exists")); + } + + continue; + } + + if (!projection.ReceivesCards) + continue; + + var projectedCount = column.Cards.Count + projection.Delta; + if (column.WipLimit.HasValue && projectedCount > column.WipLimit.Value) + { + flaggedColumnIds.Add(columnId); + rows.Add(new ConflictRow( + ConflictTone.Warn, + "wip-limit", + $"Column \"{column.Name}\" would exceed WIP limit ({projectedCount}/{column.WipLimit.Value})")); + } + } + } + + /// + /// Warn: another pending proposal targets the same card. + /// Queries for ANY pending proposals on the target card, not just the latest. + /// + private async Task CheckDuplicatePendingProposalsAsync( + AutomationProposal proposal, + List rows, + CancellationToken cancellationToken) + { + var cardTargetIds = GetDistinctCardTargetIds(proposal, includeCreate: true); + if (cardTargetIds.Count == 0) return; + + foreach (var cardId in cardTargetIds) + { + var pendingProposals = await _unitOfWork.AutomationProposals + .GetPendingByOperationTargetAsync("card", cardId.ToString("D"), cancellationToken); + + var hasDuplicate = pendingProposals.Any(p => p.Id != proposal.Id); + if (hasDuplicate) + { + rows.Add(new ConflictRow( + ConflictTone.Warn, + "duplicate-proposal", + $"Another pending proposal also targets card {cardId:N}")); + } + } + } + + /// + /// Warn: proposal risk level is High or Critical. + /// + private static void CheckHighRiskOperations( + AutomationProposal proposal, + List rows) + { + if (proposal.RiskLevel is RiskLevel.High or RiskLevel.Critical) + { + rows.Add(new ConflictRow( + ConflictTone.Warn, + "high-risk", + $"Proposal risk level is {proposal.RiskLevel}")); + } + } + + /// + /// Info: proposal will trigger outbound webhooks. + /// + private async Task CheckOutboundWebhooksAsync( + AutomationProposal proposal, + List rows, + CancellationToken cancellationToken) + { + if (!proposal.BoardId.HasValue) return; + + var eventTypes = GetWebhookEventTypes(proposal); + if (eventTypes.Count == 0) return; + + var webhooks = await _unitOfWork.OutboundWebhookSubscriptions + .GetActiveByBoardAsync(proposal.BoardId.Value, cancellationToken); + + var matchingWebhookCount = webhooks + .Count(webhook => eventTypes.Any(webhook.MatchesEvent)); + + if (matchingWebhookCount > 0) + { + rows.Add(new ConflictRow( + ConflictTone.Info, + "webhooks", + $"This proposal will trigger {matchingWebhookCount} outbound webhook(s)")); + } + } + + /// + /// Info: target card has active comments/discussion. + /// + private async Task CheckActiveCommentsAsync( + AutomationProposal proposal, + List rows, + CancellationToken cancellationToken) + { + var cardTargetIds = GetDistinctCardTargetIds(proposal, includeCreate: false); + if (cardTargetIds.Count == 0) return; + + foreach (var cardId in cardTargetIds) + { + var commentCount = await _unitOfWork.CardComments.CountByCardIdAsync(cardId, cancellationToken); + if (commentCount > 0) + { + rows.Add(new ConflictRow( + ConflictTone.Info, + "active-comments", + $"Card {cardId:N} has {commentCount} comment(s)")); + } + } + } + + /// + /// Info: multiple operations in the proposal affect the same card. + /// + private static void CheckMultipleOperationsOnSameCard( + AutomationProposal proposal, + List rows) + { + var cardOps = proposal.Operations + .Where(op => op.TargetType.Equals("card", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(op.TargetId)) + .GroupBy(op => op.TargetId!, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .ToList(); + + foreach (var group in cardOps) + { + rows.Add(new ConflictRow( + ConflictTone.Info, + "multi-op", + $"Card {group.Key} is affected by {group.Count()} operations in this proposal")); + } + } + + /// + /// Add positive Ok signals for target columns with capacity and fresh card data. + /// Only added when there are already some warn/info rows (otherwise the "no conflicts" row covers it). + /// Reuses cached entities to avoid redundant DB lookups. + /// + private async Task AddPositiveSignalsAsync( + AutomationProposal proposal, + List rows, + HashSet flaggedCardIds, + HashSet flaggedColumnIds, + Dictionary cardCache, + Dictionary columnCache, + IReadOnlyDictionary projectedColumnChanges, + CancellationToken cancellationToken) + { + // Ok: target column has capacity (only if we didn't already warn about WIP for this column) + foreach (var (columnId, projection) in projectedColumnChanges) + { + if (!projection.ReceivesCards) continue; + if (flaggedColumnIds.Contains(columnId)) continue; + + var column = await GetOrFetchColumnAsync(columnId, columnCache, cancellationToken); + if (column is null) continue; + + var projectedCount = column.Cards.Count + projection.Delta; + if (column.WipLimit.HasValue && projectedCount <= column.WipLimit.Value) + { + rows.Add(new ConflictRow( + ConflictTone.Ok, + "capacity", + $"Column \"{column.Name}\" has projected capacity ({projectedCount}/{column.WipLimit.Value})")); + } + } + + // Ok: card data is fresh (only for cards we didn't already flag as stale/missing) + var cardTargetIds = GetDistinctCardTargetIds(proposal, includeCreate: false); + foreach (var cardId in cardTargetIds) + { + if (flaggedCardIds.Contains(cardId)) continue; + + var card = await GetOrFetchCardAsync(cardId, cardCache, cancellationToken); + if (card is not null) + { + rows.Add(new ConflictRow( + ConflictTone.Ok, + "fresh-data", + $"Card \"{card.Title}\" data is current")); + } + } + } + + /// + /// Extracts distinct card GUIDs from proposal operations that target cards. + /// Excludes create operations since those cards don't exist yet and would + /// produce false stale/missing warnings. + /// + private static List GetDistinctCardTargetIds(AutomationProposal proposal, bool includeCreate) + { + return proposal.Operations + .Where(op => op.TargetType.Equals("card", StringComparison.OrdinalIgnoreCase) + && (includeCreate || !op.ActionType.Equals("create", StringComparison.OrdinalIgnoreCase)) + && !string.IsNullOrEmpty(op.TargetId) + && Guid.TryParse(op.TargetId, out _)) + .Select(op => Guid.Parse(op.TargetId!)) + .Distinct() + .ToList(); + } + + /// + /// Projects card count deltas per column for create/move operations. + /// Existing cards moved within their current column do not increase projected WIP. + /// + private async Task> GetProjectedColumnChangesAsync( + AutomationProposal proposal, + Dictionary cardCache, + CancellationToken cancellationToken) + { + var changes = new Dictionary(); + + foreach (var op in proposal.Operations) + { + if (!AddsCardToColumn(op)) + continue; + + var targetColumnId = TryGetTargetColumnId(op); + if (!targetColumnId.HasValue) + continue; + + var sourceColumnId = (Guid?)null; + if (op.ActionType.Equals("move", StringComparison.OrdinalIgnoreCase) + && op.TargetType.Equals("card", StringComparison.OrdinalIgnoreCase) + && Guid.TryParse(op.TargetId, out var movedCardId)) + { + var card = await GetOrFetchCardAsync(movedCardId, cardCache, cancellationToken); + if (card?.ColumnId == targetColumnId.Value) + continue; + + sourceColumnId = card?.ColumnId; + } + + if (sourceColumnId.HasValue) + AddColumnProjectionDelta(changes, sourceColumnId.Value, delta: -1, receivesCards: false); + + AddColumnProjectionDelta(changes, targetColumnId.Value, delta: 1, receivesCards: true); + } + + return changes; + } + + private static void AddColumnProjectionDelta( + Dictionary changes, + Guid columnId, + int delta, + bool receivesCards) + { + var existing = changes.GetValueOrDefault(columnId); + changes[columnId] = new ColumnProjection( + existing.Delta + delta, + existing.ReceivesCards || receivesCards); + } + + /// + /// Extracts a target column ID from an operation that moves or creates into a column. + /// Checks TargetType before Parameters so column-targeted operations with no + /// parameters are still detected. Parses JSON parameters for "columnId" or + /// "targetColumnId" fields when present. + /// + private static Guid? TryGetTargetColumnId(AutomationProposalOperation op) + { + // Target columns for column-targeted card movement/creation operations. + if (op.TargetType.Equals("column", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(op.TargetId) + && Guid.TryParse(op.TargetId, out var colTargetId)) + { + return colTargetId; + } + + if (string.IsNullOrWhiteSpace(op.Parameters)) return null; + + // Parse parameters JSON for columnId / targetColumnId fields + try + { + using var doc = System.Text.Json.JsonDocument.Parse(op.Parameters); + if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) + return null; + + if (doc.RootElement.TryGetProperty("columnId", out var colProp) + && colProp.ValueKind == System.Text.Json.JsonValueKind.String + && Guid.TryParse(colProp.GetString(), out var columnId)) + { + return columnId; + } + + if (doc.RootElement.TryGetProperty("targetColumnId", out var targetColProp) + && targetColProp.ValueKind == System.Text.Json.JsonValueKind.String + && Guid.TryParse(targetColProp.GetString(), out var targetColumnId)) + { + return targetColumnId; + } + } + catch (System.Text.Json.JsonException) + { + // Malformed JSON in parameters -- skip silently + } + + return null; + } + + private static bool AddsCardToColumn(AutomationProposalOperation operation) + { + return operation.ActionType.Equals("create", StringComparison.OrdinalIgnoreCase) + || operation.ActionType.Equals("move", StringComparison.OrdinalIgnoreCase); + } + + private readonly record struct ColumnProjection(int Delta, bool ReceivesCards); + + private static IReadOnlyList GetWebhookEventTypes(AutomationProposal proposal) + { + return proposal.Operations + .Select(ToWebhookEventType) + .Where(eventType => eventType is not null) + .Distinct(StringComparer.Ordinal) + .Select(eventType => eventType!) + .ToList(); + } + + private static string? ToWebhookEventType(AutomationProposalOperation operation) + { + if (string.IsNullOrWhiteSpace(operation.TargetType) || string.IsNullOrWhiteSpace(operation.ActionType)) + return null; + + var entityType = operation.TargetType.Trim().ToLowerInvariant(); + var eventOperation = operation.ActionType.Trim().ToLowerInvariant() switch + { + "create" or "add" => "created", + "move" => "moved", + "delete" or "remove" => "deleted", + "archive" or "update" or "set" or "rename" or "reorder" or "assign" or "attach" or "block" or "unblock" or "restore" or "unarchive" => "updated", + _ => null + }; + + return eventOperation is null ? null : $"{entityType}.{eventOperation}"; + } + + /// + /// Fetches a card by ID, using the cache to avoid redundant lookups. + /// + private async Task GetOrFetchCardAsync( + Guid cardId, + Dictionary cache, + CancellationToken cancellationToken) + { + if (cache.TryGetValue(cardId, out var cached)) + return cached; + + var card = await _unitOfWork.Cards.GetByIdAsync(cardId, cancellationToken); + cache[cardId] = card; + return card; + } + + /// + /// Fetches a column with cards by ID, using the cache to avoid redundant lookups. + /// + private async Task GetOrFetchColumnAsync( + Guid columnId, + Dictionary cache, + CancellationToken cancellationToken) + { + if (cache.TryGetValue(columnId, out var cached)) + return cached; + + var column = await _unitOfWork.Columns.GetByIdWithCardsAsync(columnId, cancellationToken); + cache[columnId] = column; + return column; + } +} diff --git a/backend/src/Taskdeck.Domain/Entities/ConflictRow.cs b/backend/src/Taskdeck.Domain/Entities/ConflictRow.cs new file mode 100644 index 000000000..005d1f983 --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/ConflictRow.cs @@ -0,0 +1,39 @@ +namespace Taskdeck.Domain.Entities; + +/// +/// Value object representing a single conflict/warning/status row for a proposal. +/// Immutable after creation. +/// +public sealed class ConflictRow : IEquatable +{ + public ConflictTone Tone { get; } + public string Key { get; } + public string Value { get; } + + public ConflictRow(ConflictTone tone, string key, string value) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Conflict row key cannot be empty.", nameof(key)); + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Conflict row value cannot be empty.", nameof(value)); + if (!Enum.IsDefined(typeof(ConflictTone), tone)) + throw new ArgumentException($"Invalid conflict tone: {tone}", nameof(tone)); + + Tone = tone; + Key = key; + Value = value; + } + + public bool Equals(ConflictRow? other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + return Tone == other.Tone && Key == other.Key && Value == other.Value; + } + + public override bool Equals(object? obj) => Equals(obj as ConflictRow); + + public override int GetHashCode() => HashCode.Combine(Tone, Key, Value); + + public override string ToString() => $"[{Tone}] {Key}: {Value}"; +} diff --git a/backend/src/Taskdeck.Domain/Entities/ConflictTone.cs b/backend/src/Taskdeck.Domain/Entities/ConflictTone.cs new file mode 100644 index 000000000..120db720e --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/ConflictTone.cs @@ -0,0 +1,12 @@ +namespace Taskdeck.Domain.Entities; + +/// +/// Tone classification for proposal conflict/warning rows. +/// Maps to frontend color semantics: Warn = rust, Info = mute, Ok = sage. +/// +public enum ConflictTone +{ + Warn, + Info, + Ok +} diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs index e56edf260..37ebdb5f1 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs @@ -201,6 +201,55 @@ public async Task> GetByRiskLevelAsync(RiskLevel .FirstOrDefaultAsync(p => p.Id == proposalId, cancellationToken); } + public async Task> GetPendingByOperationTargetAsync( + string targetType, + string targetId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(targetType) || string.IsNullOrWhiteSpace(targetId)) + return Array.Empty(); + + var normalizedTargetType = targetType.Trim().ToLowerInvariant(); + var targetIsGuid = Guid.TryParse(targetId, out var normalizedTargetGuid); + var now = DateTime.UtcNow; + + var candidateOperations = await _context.AutomationProposalOperations + .Join( + _dbSet.Where(p => p.Status == ProposalStatus.PendingReview && p.ExpiresAt > now), + operation => operation.ProposalId, + proposal => proposal.Id, + (operation, proposal) => new + { + operation.ProposalId, + operation.TargetType, + operation.TargetId + }) + .Where(op => op.TargetType.ToLower() == normalizedTargetType && op.TargetId != null) + .ToListAsync(cancellationToken); + + var proposalIds = candidateOperations + .Where(op => TargetIdMatches(op.TargetId!, targetId, targetIsGuid, normalizedTargetGuid)) + .Select(op => op.ProposalId) + .Distinct() + .ToList(); + + if (proposalIds.Count == 0) + return Array.Empty(); + + return await _dbSet + .Include(p => p.Operations) + .Where(p => proposalIds.Contains(p.Id) && p.Status == ProposalStatus.PendingReview) + .ToListAsync(cancellationToken); + } + + private static bool TargetIdMatches(string storedTargetId, string requestedTargetId, bool requestedTargetIsGuid, Guid requestedTargetGuid) + { + if (requestedTargetIsGuid && Guid.TryParse(storedTargetId, out var storedTargetGuid)) + return storedTargetGuid == requestedTargetGuid; + + return string.Equals(storedTargetId, requestedTargetId, StringComparison.OrdinalIgnoreCase); + } + public async Task> GetExpiredAsync(CancellationToken cancellationToken = default) { var now = DateTime.UtcNow; diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/CardCommentRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/CardCommentRepository.cs index 6ae44aa7c..6f2157201 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/CardCommentRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/CardCommentRepository.cs @@ -27,6 +27,14 @@ public async Task> GetByCardIdAsync( .ToList(); } + public async Task CountByCardIdAsync( + Guid cardId, + CancellationToken cancellationToken = default) + { + return await _dbSet + .CountAsync(comment => comment.CardId == cardId && !comment.IsDeleted, cancellationToken); + } + public async Task GetByIdWithMentionsAsync( Guid id, CancellationToken cancellationToken = default) diff --git a/backend/tests/Taskdeck.Api.Tests/AutomationProposalRepositoryIntegrationTests.cs b/backend/tests/Taskdeck.Api.Tests/AutomationProposalRepositoryIntegrationTests.cs index 3998dcedf..f5a84c890 100644 --- a/backend/tests/Taskdeck.Api.Tests/AutomationProposalRepositoryIntegrationTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/AutomationProposalRepositoryIntegrationTests.cs @@ -390,6 +390,41 @@ await db.Database.ExecuteSqlInterpolatedAsync( results.Should().NotContain(p => p.Id == proposal.Id); } + [Fact] + public async Task GetPendingByOperationTargetAsync_NormalizesGuidAndTargetType_ExcludesExpired() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var user = new User($"ap-target-user-{Guid.NewGuid():N}", $"ap-target-{Guid.NewGuid():N}@example.com", "hash"); + db.Users.Add(user); + + var targetId = Guid.NewGuid(); + var pending = new AutomationProposal( + ProposalSourceType.Queue, user.Id, "Pending target", RiskLevel.Low, + $"corr-pending-target-{Guid.NewGuid():N}"); + pending.AddOperation(new AutomationProposalOperation( + pending.Id, 0, "update", "Card", "{\"title\":\"Updated\"}", + $"key-pending-{Guid.NewGuid():N}", targetId: targetId.ToString("B"))); + + var expired = new AutomationProposal( + ProposalSourceType.Queue, user.Id, "Expired target", RiskLevel.Low, + $"corr-expired-target-{Guid.NewGuid():N}"); + expired.AddOperation(new AutomationProposalOperation( + expired.Id, 0, "update", "card", "{\"title\":\"Expired\"}", + $"key-expired-{Guid.NewGuid():N}", targetId: targetId.ToString("D").ToUpperInvariant())); + SetExpiresAt(expired, DateTime.UtcNow.AddMinutes(-1)); + + db.AutomationProposals.AddRange(pending, expired); + await db.SaveChangesAsync(); + + var results = await repo.GetPendingByOperationTargetAsync(" CARD ", targetId.ToString("N").ToUpperInvariant()); + + results.Should().ContainSingle(p => p.Id == pending.Id); + results.Should().NotContain(p => p.Id == expired.Id); + } + private static void SetExpiresAt(AutomationProposal proposal, DateTime expiresAt) { typeof(AutomationProposal) diff --git a/backend/tests/Taskdeck.Api.Tests/CardCommentRepositoryIntegrationTests.cs b/backend/tests/Taskdeck.Api.Tests/CardCommentRepositoryIntegrationTests.cs new file mode 100644 index 000000000..054ce3d32 --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/CardCommentRepositoryIntegrationTests.cs @@ -0,0 +1,45 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Entities; +using Taskdeck.Infrastructure.Persistence; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class CardCommentRepositoryIntegrationTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + + public CardCommentRepositoryIntegrationTests(TestWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task CountByCardIdAsync_ExcludesSoftDeletedComments() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var repo = scope.ServiceProvider.GetRequiredService(); + + var user = new User($"comment-{Guid.NewGuid():N}", $"comment-count-{Guid.NewGuid():N}@example.com", "hash"); + var board = new Board("Comment count board", ownerId: user.Id); + var column = new Column(board.Id, "Todo", 0); + var card = new Card(board.Id, column.Id, "Commented card"); + var activeComment = new CardComment(card.Id, board.Id, user.Id, "Still active"); + var deletedComment = new CardComment(card.Id, board.Id, user.Id, "Now deleted"); + deletedComment.SoftDelete(); + + db.Users.Add(user); + db.Boards.Add(board); + db.Columns.Add(column); + db.Cards.Add(card); + db.CardComments.AddRange(activeComment, deletedComment); + await db.SaveChangesAsync(); + + var count = await repo.CountByCardIdAsync(card.Id); + + count.Should().Be(1); + } +} diff --git a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs index caf7e0db1..586ccb396 100644 --- a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs @@ -279,6 +279,7 @@ public Task> GetByStatusAsync( public Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetLatestByOperationTargetAsync(string targetType, string targetId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetLatestByOperationTargetAsync(string targetType, string targetId, string actionType, ProposalSourceType sourceType, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetPendingByOperationTargetAsync(string targetType, string targetId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetExpiredAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetTerminalByActionTypeAsync(string actionType, Guid? boardId, Guid userId, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } @@ -304,6 +305,7 @@ public Task> GetByStatusAsync( public Task GetByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetLatestByOperationTargetAsync(string targetType, string targetId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetLatestByOperationTargetAsync(string targetType, string targetId, string actionType, ProposalSourceType sourceType, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetPendingByOperationTargetAsync(string targetType, string targetId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetExpiredAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetTerminalByActionTypeAsync(string actionType, Guid? boardId, Guid userId, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } diff --git a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs index 0b85317b5..dd6c9bba5 100644 --- a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs @@ -181,6 +181,14 @@ public Task> GetByRiskLevelAsync(RiskLevel riskL throw new NotSupportedException(); } + public Task> GetPendingByOperationTargetAsync( + string targetType, + string targetId, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + public Task> GetExpiredAsync(CancellationToken cancellationToken = default) { throw new NotSupportedException(); diff --git a/backend/tests/Taskdeck.Api.Tests/StarterPacksApiTests.cs b/backend/tests/Taskdeck.Api.Tests/StarterPacksApiTests.cs index a1dc91688..2ba7628a9 100644 --- a/backend/tests/Taskdeck.Api.Tests/StarterPacksApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/StarterPacksApiTests.cs @@ -1,777 +1,777 @@ -using System.Net; -using System.Net.Http.Json; -using FluentAssertions; -using Taskdeck.Api.Tests.Support; -using Taskdeck.Application.DTOs; -using Xunit; - -namespace Taskdeck.Api.Tests; - -public class StarterPacksApiTests : IClassFixture -{ - private readonly HttpClient _client; - private bool _isAuthenticated; - - public StarterPacksApiTests(TestWebApplicationFactory factory) - { - _client = factory.CreateClient(); - } - - [Fact] - public async Task GetStarterPackCatalog_ShouldReturnUnauthorized_WhenUnauthenticated() - { - var response = await _client.GetAsync($"/api/boards/{Guid.NewGuid()}/starter-packs/catalog"); - - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - - [Fact] - public async Task GetStarterPackCatalog_ShouldReturnFirstPartyPacks_WhenBoardReadable() - { - var board = await CreateBoardAsync(); - - var response = await _client.GetAsync($"/api/boards/{board.Id}/starter-packs/catalog"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var payload = await response.Content.ReadFromJsonAsync>(); - var catalog = payload ?? throw new InvalidOperationException("Starter-pack catalog response payload should not be null."); - catalog.Should().OnlyHaveUniqueItems(entry => entry.Id); - catalog.Count(entry => entry.Category == StarterPackCatalogCategories.LabelPack).Should().BeGreaterThanOrEqualTo(1); - catalog.Count(entry => entry.Category == StarterPackCatalogCategories.ColumnFlow).Should().BeGreaterThanOrEqualTo(1); - catalog.Count(entry => entry.Category == StarterPackCatalogCategories.BoardBlueprint).Should().Be(9); - catalog.Should().ContainSingle(entry => entry.Id == "board-blueprint-client-onboarding"); - } - - [Fact] - public async Task GetStarterPackCatalog_ShouldReturnForbidden_WhenUserHasNoBoardAccess() - { - var board = await CreateBoardAsync(); - - await ApiTestHarness.AuthenticateAsync(_client, "starter-pack-other-user"); - _isAuthenticated = true; - - var response = await _client.GetAsync($"/api/boards/{board.Id}/starter-packs/catalog"); - - await ApiTestHarness.AssertForbiddenAsync(response); - } - - [Fact] - public async Task ValidateManifest_ShouldReturnValid_WhenManifestJsonIsCorrect() - { - var board = await CreateBoardAsync(); - - var manifestJson = System.Text.Json.JsonSerializer.Serialize(BuildCanonicalManifest()); - var response = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/validate-manifest", - new ValidateManifestJsonDto(manifestJson)); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var payload = await response.Content.ReadFromJsonAsync(); - payload.Should().NotBeNull(); - payload!.IsValid.Should().BeTrue(); - payload.Errors.Should().BeEmpty(); - payload.Manifest.Should().NotBeNull(); - payload.Manifest!.PackId.Should().Be("engineering-onboarding"); - } - - [Fact] - public async Task ValidateManifest_ShouldReturnErrors_WhenManifestJsonIsInvalid() - { - var board = await CreateBoardAsync(); - - var response = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/validate-manifest", - new ValidateManifestJsonDto("{\"schemaVersion\":\"1.0\",\"packId\":\"!!invalid!!\"}")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var payload = await response.Content.ReadFromJsonAsync(); - payload.Should().NotBeNull(); - payload!.IsValid.Should().BeFalse(); - payload.Errors.Should().NotBeEmpty(); - payload.Errors.Should().Contain(e => e.Path == "$.packId"); - } - - [Fact] - public async Task ValidateManifest_ShouldReturnErrors_WhenManifestJsonIsMalformed() - { - var board = await CreateBoardAsync(); - - var response = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/validate-manifest", - new ValidateManifestJsonDto("{not valid json}")); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var payload = await response.Content.ReadFromJsonAsync(); - payload.Should().NotBeNull(); - payload!.IsValid.Should().BeFalse(); - payload.Errors.Should().NotBeEmpty(); - payload.Errors.Should().Contain(e => e.Path == "$"); - } - - [Fact] - public async Task ValidateManifest_ShouldReturnUnauthorized_WhenUnauthenticated() - { - var response = await _client.PostAsJsonAsync( - $"/api/boards/{Guid.NewGuid()}/starter-packs/validate-manifest", - new ValidateManifestJsonDto("{}")); - - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } - - [Fact] - public async Task ApplyStarterPack_ShouldReturnForbidden_WhenUserHasNoBoardAccess() - { - var board = await CreateBoardAsync(); - await ApiTestHarness.AuthenticateAsync(_client, "starter-pack-apply-outsider"); - _isAuthenticated = true; - - var response = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/apply", - new ApplyStarterPackDto(BuildCanonicalManifest(), DryRun: false)); - - await ApiTestHarness.AssertForbiddenAsync(response); - } - - [Fact] - public async Task ApplyStarterPack_ShouldReturnNotFound_WhenBoardDoesNotExist() - { - await EnsureAuthenticatedAsync(); - - var response = await _client.PostAsJsonAsync( - $"/api/boards/{Guid.NewGuid()}/starter-packs/apply", - new ApplyStarterPackDto(BuildCanonicalManifest(), DryRun: false)); - - await ApiTestHarness.AssertErrorContractAsync(response, HttpStatusCode.NotFound, "NotFound"); - } - - [Fact] - public async Task ApplyStarterPack_ShouldCreateBoardArtifacts_WhenManifestIsValid() - { - var board = await CreateBoardAsync(); - var request = new ApplyStarterPackDto(BuildCanonicalManifest(), DryRun: false); - - var response = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/apply", - request); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var payload = await response.Content.ReadFromJsonAsync(); - payload.Should().NotBeNull(); - payload!.Applied.Should().BeTrue(); - payload.HasConflicts.Should().BeFalse(); - - var labelsResponse = await _client.GetAsync($"/api/boards/{board.Id}/labels"); - labelsResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var labels = await labelsResponse.Content.ReadFromJsonAsync>(); - labels.Should().NotBeNull(); - labels!.Should().ContainSingle(label => label.Name == "priority-high"); - - var columnsResponse = await _client.GetAsync($"/api/boards/{board.Id}/columns"); - columnsResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var columns = await columnsResponse.Content.ReadFromJsonAsync>(); - columns.Should().NotBeNull(); - columns!.Should().HaveCount(2); - columns.Should().Contain(column => column.Name == "Backlog" && column.Position == 0); - columns.Should().Contain(column => column.Name == "Done" && column.Position == 1); - - var cardsResponse = await _client.GetAsync($"/api/boards/{board.Id}/cards"); - cardsResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var cards = await cardsResponse.Content.ReadFromJsonAsync>(); - cards.Should().NotBeNull(); - cards!.Should().ContainSingle(card => - card.Title == "Set up sprint board" && - card.Labels.Any(label => label.Name == "priority-high")); - } - - [Fact] - public async Task ApplyStarterPack_ShouldAllowLabelOnlyPack_WhenBoardHasDuplicateColumnNames() - { - var board = await CreateBoardAsync(); - - var firstColumnResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/columns", - new CreateColumnDto(board.Id, "duplicate-column", 0, null)); - firstColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var secondColumnResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/columns", - new CreateColumnDto(board.Id, "Duplicate-Column", 1, null)); - secondColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var response = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/apply", - new ApplyStarterPackDto(BuildLabelOnlyManifest(), DryRun: false)); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var payload = await response.Content.ReadFromJsonAsync(); - payload.Should().NotBeNull(); - payload!.Applied.Should().BeTrue(); - payload.HasBlockingConflicts.Should().BeFalse(); - payload.Conflicts.Should().NotContain(conflict => - conflict.Code == "ExistingColumnNameConflict" || - conflict.Code == "ExistingColumnPositionConflict"); - - var labels = await _client.GetFromJsonAsync>($"/api/boards/{board.Id}/labels"); - labels.Should().NotBeNull(); - labels!.Should().ContainSingle(label => label.Name == "priority-high"); - } - - [Fact] - public async Task ApplyStarterPack_ShouldAllowColumnOnlyPack_WhenBoardHasDuplicateLabelNames() - { - var board = await CreateBoardAsync(); - - var firstLabelResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/labels", - new CreateLabelDto(board.Id, "duplicate-label", "#111111")); - firstLabelResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var secondLabelResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/labels", - new CreateLabelDto(board.Id, "Duplicate-Label", "#222222")); - secondLabelResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var response = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/apply", - new ApplyStarterPackDto(BuildColumnOnlyManifest(), DryRun: false)); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var payload = await response.Content.ReadFromJsonAsync(); - payload.Should().NotBeNull(); - payload!.Applied.Should().BeTrue(); - payload.HasBlockingConflicts.Should().BeFalse(); - payload.Conflicts.Should().NotContain(conflict => conflict.Code == "ExistingLabelNameConflict"); - - var columns = await _client.GetFromJsonAsync>($"/api/boards/{board.Id}/columns"); - columns.Should().NotBeNull(); - columns!.Should().ContainSingle(column => column.Name == "Backlog" && column.Position == 0); - columns.Should().ContainSingle(column => column.Name == "Done" && column.Position == 1); - } - - [Fact] - public async Task ApplyStarterPack_ShouldAllowCanonicalPack_WhenBoardHasUnrelatedDuplicateLabelNames() - { - var board = await CreateBoardAsync(); - - var firstLabelResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/labels", - new CreateLabelDto(board.Id, "legacy-label", "#111111")); - firstLabelResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var secondLabelResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/labels", - new CreateLabelDto(board.Id, "Legacy-Label", "#222222")); - secondLabelResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var response = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/apply", - new ApplyStarterPackDto(BuildCanonicalManifest(), DryRun: false)); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var payload = await response.Content.ReadFromJsonAsync(); - payload.Should().NotBeNull(); - payload!.Applied.Should().BeTrue(); - payload.HasBlockingConflicts.Should().BeFalse(); - payload.Conflicts.Should().NotContain(conflict => - conflict.Code == "ExistingLabelNameConflict" && - string.Equals(conflict.IncomingValue, "legacy-label", StringComparison.OrdinalIgnoreCase)); - - var labels = await _client.GetFromJsonAsync>($"/api/boards/{board.Id}/labels"); - labels.Should().NotBeNull(); - labels!.Should().ContainSingle(label => label.Name == "priority-high"); - } - - [Fact] - public async Task ApplyStarterPack_ShouldAllowCanonicalPack_WhenBoardHasUnrelatedDuplicateColumnNames() - { - var board = await CreateBoardAsync(); - - var firstColumnResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/columns", - new CreateColumnDto(board.Id, "legacy-column", 10, null)); - firstColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var secondColumnResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/columns", - new CreateColumnDto(board.Id, "Legacy-Column", 11, null)); - secondColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var response = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/apply", - new ApplyStarterPackDto(BuildCanonicalManifest(), DryRun: false)); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var payload = await response.Content.ReadFromJsonAsync(); - payload.Should().NotBeNull(); - payload!.Applied.Should().BeTrue(); - payload.HasBlockingConflicts.Should().BeFalse(); - payload.Conflicts.Should().NotContain(conflict => - conflict.Code == "ExistingColumnNameConflict" && - string.Equals(conflict.IncomingValue, "legacy-column", StringComparison.OrdinalIgnoreCase)); - - var columns = await _client.GetFromJsonAsync>($"/api/boards/{board.Id}/columns"); - columns.Should().NotBeNull(); - columns!.Should().ContainSingle(column => column.Name == "Backlog" && column.Position == 0); - columns.Should().ContainSingle(column => column.Name == "Done" && column.Position == 1); - } - - [Fact] - public async Task ApplyStarterPack_ShouldBeIdempotent_WhenReapplied() - { - var board = await CreateBoardAsync(); - var request = new ApplyStarterPackDto(BuildCanonicalManifest(), DryRun: false); - - var firstApplyResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/apply", - request); - firstApplyResponse.StatusCode.Should().Be(HttpStatusCode.OK); - - var secondApplyResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/apply", - request); - - secondApplyResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var payload = await secondApplyResponse.Content.ReadFromJsonAsync(); - payload.Should().NotBeNull(); - payload!.Applied.Should().BeTrue(); - payload.HasConflicts.Should().BeTrue(); - payload.HasBlockingConflicts.Should().BeFalse(); - payload.Actions.Should().Contain(action => - action.EntityType == "label" && - action.Operation == "skip" && - action.Key == "priority-high"); - payload.Actions.Should().Contain(action => - action.EntityType == "column" && - action.Operation == "skip" && - action.Key == "Backlog"); - payload.Actions.Should().Contain(action => - action.EntityType == "seedCard" && - action.Operation == "skip" && - action.Key.Contains("Set up sprint board", StringComparison.Ordinal)); - payload.Conflicts.Should().Contain(conflict => - conflict.Code == "SeedCardAlreadyExistsConflict" && - conflict.Severity == StarterPackConflictSeverity.Warning); - - var labels = await _client.GetFromJsonAsync>($"/api/boards/{board.Id}/labels"); - labels.Should().NotBeNull(); - labels!.Count(label => string.Equals(label.Name, "priority-high", StringComparison.OrdinalIgnoreCase)) - .Should() - .Be(1); - - var columns = await _client.GetFromJsonAsync>($"/api/boards/{board.Id}/columns"); - columns.Should().NotBeNull(); - columns!.Should().HaveCount(2); - - var cards = await _client.GetFromJsonAsync>($"/api/boards/{board.Id}/cards"); - cards.Should().NotBeNull(); - cards!.Count(card => string.Equals(card.Title, "Set up sprint board", StringComparison.OrdinalIgnoreCase)) - .Should() - .Be(1); - } - - [Fact] - public async Task ApplyStarterPack_DryRun_ShouldReturnActionableConflictReport() - { - var board = await CreateBoardAsync(); - var existingColumnResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/columns", - new CreateColumnDto(board.Id, "Existing", 0, null)); - existingColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var dryRunRequest = new ApplyStarterPackDto(BuildPositionConflictManifest(), DryRun: true); - var response = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/apply", - dryRunRequest); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var payload = await response.Content.ReadFromJsonAsync(); - payload.Should().NotBeNull(); - payload!.Applied.Should().BeFalse(); - payload.HasConflicts.Should().BeTrue(); - payload.Conflicts.Should().Contain(conflict => - conflict.Code == "ColumnPositionConflict" && - conflict.Path == "$.columns[0].position" && - conflict.ExistingValue == "Existing" && - conflict.IncomingValue == "Backlog"); - } - - [Fact] - public async Task ApplyStarterPack_DryRun_ShouldIncludeSeedCardSkipAction_WhenSeedCardReferencesUnresolvableColumn() - { - var board = await CreateBoardAsync(); - var existingColumnResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/columns", - new CreateColumnDto(board.Id, "Existing", 0, null)); - existingColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var applyRequest = new ApplyStarterPackDto(BuildUnresolvableSeedCardManifest(), DryRun: true); - - var response = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/apply", - applyRequest); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - var payload = await response.Content.ReadFromJsonAsync(); - payload.Should().NotBeNull(); - payload!.Applied.Should().BeFalse(); - payload.HasConflicts.Should().BeTrue(); - payload.HasBlockingConflicts.Should().BeTrue(); - payload.Conflicts.Should().Contain(conflict => - conflict.Code == "ColumnPositionConflict"); - payload.Conflicts.Should().Contain(conflict => - conflict.Code == "SeedCardColumnConflict" && - conflict.Severity == StarterPackConflictSeverity.Warning); - - payload.Actions.Count(action => - action.EntityType == "seedCard" && - action.Operation == "skip" && - action.Key == "Investigate intake @ Backlog") - .Should() - .Be(1); - } - - [Fact] - public async Task ApplyStarterPack_ShouldReturnConflict_WhenApplyHasConflicts() - { - var board = await CreateBoardAsync(); - var existingColumnResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/columns", - new CreateColumnDto(board.Id, "Existing", 0, null)); - existingColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var applyRequest = new ApplyStarterPackDto(BuildPositionConflictManifest(), DryRun: false); - var response = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/apply", - applyRequest); - - response.StatusCode.Should().Be(HttpStatusCode.Conflict); - var payload = await response.Content.ReadFromJsonAsync(); - payload.Should().NotBeNull(); - payload!.Applied.Should().BeFalse(); - payload.HasConflicts.Should().BeTrue(); - payload.Conflicts.Should().Contain(conflict => conflict.Code == "ColumnPositionConflict"); - - var columns = await _client.GetFromJsonAsync>($"/api/boards/{board.Id}/columns"); - columns.Should().NotBeNull(); - columns!.Should().ContainSingle(column => column.Name == "Existing"); - columns.Should().NotContain(column => column.Name == "Backlog"); - } - - [Fact] - public async Task ApplyStarterPack_ShouldReturnValidationError_WhenManifestIsEmpty() - { - var board = await CreateBoardAsync(); - - var response = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/apply", - new ApplyStarterPackDto(BuildEmptyManifest(), DryRun: false)); - - await ApiTestHarness.AssertErrorContractAsync(response, HttpStatusCode.BadRequest, "ValidationError"); - } - - [Fact] - public async Task ApplyStarterPack_ShouldReturnValidationError_WhenManifestContainsOnlyTemplates() - { - var board = await CreateBoardAsync(); - - var response = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/apply", - new ApplyStarterPackDto(BuildTemplateOnlyManifest(), DryRun: false)); - - await ApiTestHarness.AssertErrorContractAsync(response, HttpStatusCode.BadRequest, "ValidationError"); - } - - [Fact] - public async Task ApplyStarterPack_ShouldReturnConflict_WhenBoardContainsDuplicateNamesUsedByManifest() - { - var board = await CreateBoardAsync(); - - var firstLabelResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/labels", - new CreateLabelDto(board.Id, "priority-high", "#111111")); - firstLabelResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var secondLabelResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/labels", - new CreateLabelDto(board.Id, "Priority-High", "#222222")); - secondLabelResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var firstColumnResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/columns", - new CreateColumnDto(board.Id, "Backlog", 10, null)); - firstColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var secondColumnResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/columns", - new CreateColumnDto(board.Id, "backlog", 11, null)); - secondColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); - - var applyResponse = await _client.PostAsJsonAsync( - $"/api/boards/{board.Id}/starter-packs/apply", - new ApplyStarterPackDto(BuildCanonicalManifest(), DryRun: false)); - - applyResponse.StatusCode.Should().Be(HttpStatusCode.Conflict); - var payload = await applyResponse.Content.ReadFromJsonAsync(); - payload.Should().NotBeNull(); - payload!.Applied.Should().BeFalse(); - payload.HasConflicts.Should().BeTrue(); - payload.Conflicts.Should().Contain(conflict => - conflict.Code == "ExistingLabelNameConflict" && - conflict.Path == "$.board.labels"); - payload.Conflicts.Should().Contain(conflict => - conflict.Code == "ExistingColumnNameConflict" && - conflict.Path == "$.board.columns"); - } - - private async Task CreateBoardAsync() - { - await EnsureAuthenticatedAsync(); - return await ApiTestHarness.CreateBoardAsync(_client, "starter-pack-board", "Starter pack integration tests"); - } - - private async Task EnsureAuthenticatedAsync() - { - if (_isAuthenticated) - { - return; - } - - await ApiTestHarness.AuthenticateAsync(_client, "starter-pack-suite"); - _isAuthenticated = true; - } - - private static StarterPackManifestDto BuildCanonicalManifest() - { - return new StarterPackManifestDto - { - SchemaVersion = "1.0", - PackId = "engineering-onboarding", - DisplayName = "Engineering Onboarding", - Description = "Baseline board setup for engineering teams", - Compatibility = new StarterPackCompatibilityDto - { - MinTaskdeckVersion = "1.0.0", - MaxTaskdeckVersion = "2.0.0", - RequiredFeatures = ["boards", "labels"] - }, - Tags = ["starter", "engineering"], - Labels = - [ - new StarterPackLabelDto - { - Name = "priority-high", - Color = "#E85D5D", - Description = "High urgency" - } - ], - Columns = - [ - new StarterPackColumnDto - { - Name = "Backlog", - Position = 0 - }, - new StarterPackColumnDto - { - Name = "Done", - Position = 1 - } - ], - Templates = - [ - new StarterPackCardTemplateDto - { - TemplateId = "bug-report", - Title = "Bug Report", - Description = "Template for bug triage", - Checklist = ["Reproduction steps", "Expected behavior", "Actual behavior"] - } - ], - SeedCards = - [ - new StarterPackSeedCardDto - { - Title = "Set up sprint board", - Description = "Create initial sprint lanes", - ColumnName = "Backlog", - TemplateId = "bug-report", - Labels = ["priority-high"] - } - ] - }; - } - - private static StarterPackManifestDto BuildLabelOnlyManifest() - { - return new StarterPackManifestDto - { - SchemaVersion = "1.0", - PackId = "common-labels-core", - DisplayName = "Common Labels Core", - Description = "Reusable label taxonomy for existing boards", - Compatibility = new StarterPackCompatibilityDto - { - MinTaskdeckVersion = "1.0.0", - RequiredFeatures = ["boards", "labels"] - }, - Tags = ["starter", "labels"], - Labels = - [ - new StarterPackLabelDto - { - Name = "priority-high", - Color = "#E85D5D", - Description = "High urgency" - } - ], - Columns = [], - Templates = [], - SeedCards = [] - }; - } - - private static StarterPackManifestDto BuildPositionConflictManifest() - { - return new StarterPackManifestDto - { - SchemaVersion = "1.0", - PackId = "conflict-pack", - DisplayName = "Conflict Pack", - Compatibility = new StarterPackCompatibilityDto - { - MinTaskdeckVersion = "1.0.0", - RequiredFeatures = ["boards"] - }, - Tags = ["starter"], - Labels = [], - Columns = - [ - new StarterPackColumnDto - { - Name = "Backlog", - Position = 0 - } - ], - Templates = [], - SeedCards = [] - }; - } - - private static StarterPackManifestDto BuildEmptyManifest() - { - return new StarterPackManifestDto - { - SchemaVersion = "1.0", - PackId = "empty-pack", - DisplayName = "Empty Pack", - Compatibility = new StarterPackCompatibilityDto - { - MinTaskdeckVersion = "1.0.0", - RequiredFeatures = ["boards"] - }, - Tags = ["starter"], - Labels = [], - Columns = [], - Templates = [], - SeedCards = [] - }; - } - - private static StarterPackManifestDto BuildColumnOnlyManifest() - { - return new StarterPackManifestDto - { - SchemaVersion = "1.0", - PackId = "column-only-pack", - DisplayName = "Column Only Pack", - Compatibility = new StarterPackCompatibilityDto - { - MinTaskdeckVersion = "1.0.0", - RequiredFeatures = ["boards"] - }, - Tags = ["starter", "columns"], - Labels = [], - Columns = - [ - new StarterPackColumnDto - { - Name = "Backlog", - Position = 0 - }, - new StarterPackColumnDto - { - Name = "Done", - Position = 1 - } - ], - Templates = [], - SeedCards = [] - }; - } - - private static StarterPackManifestDto BuildTemplateOnlyManifest() - { - return new StarterPackManifestDto - { - SchemaVersion = "1.0", - PackId = "template-only-pack", - DisplayName = "Template Only Pack", - Compatibility = new StarterPackCompatibilityDto - { - MinTaskdeckVersion = "1.0.0", - RequiredFeatures = ["boards"] - }, - Tags = ["starter", "templates"], - Labels = [], - Columns = [], - Templates = - [ - new StarterPackCardTemplateDto - { - TemplateId = "bug-report", - Title = "Bug Report", - Description = "Template for bug triage", - Checklist = ["Reproduction steps"] - } - ], - SeedCards = [] - }; - } - - private static StarterPackManifestDto BuildUnresolvableSeedCardManifest() - { - return new StarterPackManifestDto - { - SchemaVersion = "1.0", - PackId = "seed-card-unresolvable-warning-pack", - DisplayName = "Seed Card Unresolvable Warning Pack", - Compatibility = new StarterPackCompatibilityDto - { - MinTaskdeckVersion = "1.0.0", - RequiredFeatures = ["boards"] - }, - Tags = ["starter"], - Labels = [], - Columns = - [ - new StarterPackColumnDto - { - Name = "Backlog", - Position = 0 - } - ], - Templates = [], - SeedCards = - [ - new StarterPackSeedCardDto - { - Title = "Investigate intake", - Description = "Investigate unresolvable column references", - ColumnName = "Backlog", - Labels = [] - } - ] - }; - } - -} +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Taskdeck.Api.Tests.Support; +using Taskdeck.Application.DTOs; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class StarterPacksApiTests : IClassFixture +{ + private readonly HttpClient _client; + private bool _isAuthenticated; + + public StarterPacksApiTests(TestWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task GetStarterPackCatalog_ShouldReturnUnauthorized_WhenUnauthenticated() + { + var response = await _client.GetAsync($"/api/boards/{Guid.NewGuid()}/starter-packs/catalog"); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetStarterPackCatalog_ShouldReturnFirstPartyPacks_WhenBoardReadable() + { + var board = await CreateBoardAsync(); + + var response = await _client.GetAsync($"/api/boards/{board.Id}/starter-packs/catalog"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync>(); + var catalog = payload ?? throw new InvalidOperationException("Starter-pack catalog response payload should not be null."); + catalog.Should().OnlyHaveUniqueItems(entry => entry.Id); + catalog.Count(entry => entry.Category == StarterPackCatalogCategories.LabelPack).Should().BeGreaterThanOrEqualTo(1); + catalog.Count(entry => entry.Category == StarterPackCatalogCategories.ColumnFlow).Should().BeGreaterThanOrEqualTo(1); + catalog.Count(entry => entry.Category == StarterPackCatalogCategories.BoardBlueprint).Should().Be(9); + catalog.Should().ContainSingle(entry => entry.Id == "board-blueprint-client-onboarding"); + } + + [Fact] + public async Task GetStarterPackCatalog_ShouldReturnForbidden_WhenUserHasNoBoardAccess() + { + var board = await CreateBoardAsync(); + + await ApiTestHarness.AuthenticateAsync(_client, "starter-pack-other-user"); + _isAuthenticated = true; + + var response = await _client.GetAsync($"/api/boards/{board.Id}/starter-packs/catalog"); + + await ApiTestHarness.AssertForbiddenAsync(response); + } + + [Fact] + public async Task ValidateManifest_ShouldReturnValid_WhenManifestJsonIsCorrect() + { + var board = await CreateBoardAsync(); + + var manifestJson = System.Text.Json.JsonSerializer.Serialize(BuildCanonicalManifest()); + var response = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/validate-manifest", + new ValidateManifestJsonDto(manifestJson)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.IsValid.Should().BeTrue(); + payload.Errors.Should().BeEmpty(); + payload.Manifest.Should().NotBeNull(); + payload.Manifest!.PackId.Should().Be("engineering-onboarding"); + } + + [Fact] + public async Task ValidateManifest_ShouldReturnErrors_WhenManifestJsonIsInvalid() + { + var board = await CreateBoardAsync(); + + var response = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/validate-manifest", + new ValidateManifestJsonDto("{\"schemaVersion\":\"1.0\",\"packId\":\"!!invalid!!\"}")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.IsValid.Should().BeFalse(); + payload.Errors.Should().NotBeEmpty(); + payload.Errors.Should().Contain(e => e.Path == "$.packId"); + } + + [Fact] + public async Task ValidateManifest_ShouldReturnErrors_WhenManifestJsonIsMalformed() + { + var board = await CreateBoardAsync(); + + var response = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/validate-manifest", + new ValidateManifestJsonDto("{not valid json}")); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.IsValid.Should().BeFalse(); + payload.Errors.Should().NotBeEmpty(); + payload.Errors.Should().Contain(e => e.Path == "$"); + } + + [Fact] + public async Task ValidateManifest_ShouldReturnUnauthorized_WhenUnauthenticated() + { + var response = await _client.PostAsJsonAsync( + $"/api/boards/{Guid.NewGuid()}/starter-packs/validate-manifest", + new ValidateManifestJsonDto("{}")); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ApplyStarterPack_ShouldReturnForbidden_WhenUserHasNoBoardAccess() + { + var board = await CreateBoardAsync(); + await ApiTestHarness.AuthenticateAsync(_client, "starter-pack-apply-outsider"); + _isAuthenticated = true; + + var response = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/apply", + new ApplyStarterPackDto(BuildCanonicalManifest(), DryRun: false)); + + await ApiTestHarness.AssertForbiddenAsync(response); + } + + [Fact] + public async Task ApplyStarterPack_ShouldReturnNotFound_WhenBoardDoesNotExist() + { + await EnsureAuthenticatedAsync(); + + var response = await _client.PostAsJsonAsync( + $"/api/boards/{Guid.NewGuid()}/starter-packs/apply", + new ApplyStarterPackDto(BuildCanonicalManifest(), DryRun: false)); + + await ApiTestHarness.AssertErrorContractAsync(response, HttpStatusCode.NotFound, "NotFound"); + } + + [Fact] + public async Task ApplyStarterPack_ShouldCreateBoardArtifacts_WhenManifestIsValid() + { + var board = await CreateBoardAsync(); + var request = new ApplyStarterPackDto(BuildCanonicalManifest(), DryRun: false); + + var response = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/apply", + request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.Applied.Should().BeTrue(); + payload.HasConflicts.Should().BeFalse(); + + var labelsResponse = await _client.GetAsync($"/api/boards/{board.Id}/labels"); + labelsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var labels = await labelsResponse.Content.ReadFromJsonAsync>(); + labels.Should().NotBeNull(); + labels!.Should().ContainSingle(label => label.Name == "priority-high"); + + var columnsResponse = await _client.GetAsync($"/api/boards/{board.Id}/columns"); + columnsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var columns = await columnsResponse.Content.ReadFromJsonAsync>(); + columns.Should().NotBeNull(); + columns!.Should().HaveCount(2); + columns.Should().Contain(column => column.Name == "Backlog" && column.Position == 0); + columns.Should().Contain(column => column.Name == "Done" && column.Position == 1); + + var cardsResponse = await _client.GetAsync($"/api/boards/{board.Id}/cards"); + cardsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var cards = await cardsResponse.Content.ReadFromJsonAsync>(); + cards.Should().NotBeNull(); + cards!.Should().ContainSingle(card => + card.Title == "Set up sprint board" && + card.Labels.Any(label => label.Name == "priority-high")); + } + + [Fact] + public async Task ApplyStarterPack_ShouldAllowLabelOnlyPack_WhenBoardHasDuplicateColumnNames() + { + var board = await CreateBoardAsync(); + + var firstColumnResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/columns", + new CreateColumnDto(board.Id, "duplicate-column", 0, null)); + firstColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var secondColumnResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/columns", + new CreateColumnDto(board.Id, "Duplicate-Column", 1, null)); + secondColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var response = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/apply", + new ApplyStarterPackDto(BuildLabelOnlyManifest(), DryRun: false)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.Applied.Should().BeTrue(); + payload.HasBlockingConflicts.Should().BeFalse(); + payload.Conflicts.Should().NotContain(conflict => + conflict.Code == "ExistingColumnNameConflict" || + conflict.Code == "ExistingColumnPositionConflict"); + + var labels = await _client.GetFromJsonAsync>($"/api/boards/{board.Id}/labels"); + labels.Should().NotBeNull(); + labels!.Should().ContainSingle(label => label.Name == "priority-high"); + } + + [Fact] + public async Task ApplyStarterPack_ShouldAllowColumnOnlyPack_WhenBoardHasDuplicateLabelNames() + { + var board = await CreateBoardAsync(); + + var firstLabelResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/labels", + new CreateLabelDto(board.Id, "duplicate-label", "#111111")); + firstLabelResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var secondLabelResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/labels", + new CreateLabelDto(board.Id, "Duplicate-Label", "#222222")); + secondLabelResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var response = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/apply", + new ApplyStarterPackDto(BuildColumnOnlyManifest(), DryRun: false)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.Applied.Should().BeTrue(); + payload.HasBlockingConflicts.Should().BeFalse(); + payload.Conflicts.Should().NotContain(conflict => conflict.Code == "ExistingLabelNameConflict"); + + var columns = await _client.GetFromJsonAsync>($"/api/boards/{board.Id}/columns"); + columns.Should().NotBeNull(); + columns!.Should().ContainSingle(column => column.Name == "Backlog" && column.Position == 0); + columns.Should().ContainSingle(column => column.Name == "Done" && column.Position == 1); + } + + [Fact] + public async Task ApplyStarterPack_ShouldAllowCanonicalPack_WhenBoardHasUnrelatedDuplicateLabelNames() + { + var board = await CreateBoardAsync(); + + var firstLabelResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/labels", + new CreateLabelDto(board.Id, "legacy-label", "#111111")); + firstLabelResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var secondLabelResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/labels", + new CreateLabelDto(board.Id, "Legacy-Label", "#222222")); + secondLabelResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var response = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/apply", + new ApplyStarterPackDto(BuildCanonicalManifest(), DryRun: false)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.Applied.Should().BeTrue(); + payload.HasBlockingConflicts.Should().BeFalse(); + payload.Conflicts.Should().NotContain(conflict => + conflict.Code == "ExistingLabelNameConflict" && + string.Equals(conflict.IncomingValue, "legacy-label", StringComparison.OrdinalIgnoreCase)); + + var labels = await _client.GetFromJsonAsync>($"/api/boards/{board.Id}/labels"); + labels.Should().NotBeNull(); + labels!.Should().ContainSingle(label => label.Name == "priority-high"); + } + + [Fact] + public async Task ApplyStarterPack_ShouldAllowCanonicalPack_WhenBoardHasUnrelatedDuplicateColumnNames() + { + var board = await CreateBoardAsync(); + + var firstColumnResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/columns", + new CreateColumnDto(board.Id, "legacy-column", 10, null)); + firstColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var secondColumnResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/columns", + new CreateColumnDto(board.Id, "Legacy-Column", 11, null)); + secondColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var response = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/apply", + new ApplyStarterPackDto(BuildCanonicalManifest(), DryRun: false)); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.Applied.Should().BeTrue(); + payload.HasBlockingConflicts.Should().BeFalse(); + payload.Conflicts.Should().NotContain(conflict => + conflict.Code == "ExistingColumnNameConflict" && + string.Equals(conflict.IncomingValue, "legacy-column", StringComparison.OrdinalIgnoreCase)); + + var columns = await _client.GetFromJsonAsync>($"/api/boards/{board.Id}/columns"); + columns.Should().NotBeNull(); + columns!.Should().ContainSingle(column => column.Name == "Backlog" && column.Position == 0); + columns.Should().ContainSingle(column => column.Name == "Done" && column.Position == 1); + } + + [Fact] + public async Task ApplyStarterPack_ShouldBeIdempotent_WhenReapplied() + { + var board = await CreateBoardAsync(); + var request = new ApplyStarterPackDto(BuildCanonicalManifest(), DryRun: false); + + var firstApplyResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/apply", + request); + firstApplyResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var secondApplyResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/apply", + request); + + secondApplyResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await secondApplyResponse.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.Applied.Should().BeTrue(); + payload.HasConflicts.Should().BeTrue(); + payload.HasBlockingConflicts.Should().BeFalse(); + payload.Actions.Should().Contain(action => + action.EntityType == "label" && + action.Operation == "skip" && + action.Key == "priority-high"); + payload.Actions.Should().Contain(action => + action.EntityType == "column" && + action.Operation == "skip" && + action.Key == "Backlog"); + payload.Actions.Should().Contain(action => + action.EntityType == "seedCard" && + action.Operation == "skip" && + action.Key.Contains("Set up sprint board", StringComparison.Ordinal)); + payload.Conflicts.Should().Contain(conflict => + conflict.Code == "SeedCardAlreadyExistsConflict" && + conflict.Severity == StarterPackConflictSeverity.Warning); + + var labels = await _client.GetFromJsonAsync>($"/api/boards/{board.Id}/labels"); + labels.Should().NotBeNull(); + labels!.Count(label => string.Equals(label.Name, "priority-high", StringComparison.OrdinalIgnoreCase)) + .Should() + .Be(1); + + var columns = await _client.GetFromJsonAsync>($"/api/boards/{board.Id}/columns"); + columns.Should().NotBeNull(); + columns!.Should().HaveCount(2); + + var cards = await _client.GetFromJsonAsync>($"/api/boards/{board.Id}/cards"); + cards.Should().NotBeNull(); + cards!.Count(card => string.Equals(card.Title, "Set up sprint board", StringComparison.OrdinalIgnoreCase)) + .Should() + .Be(1); + } + + [Fact] + public async Task ApplyStarterPack_DryRun_ShouldReturnActionableConflictReport() + { + var board = await CreateBoardAsync(); + var existingColumnResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/columns", + new CreateColumnDto(board.Id, "Existing", 0, null)); + existingColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var dryRunRequest = new ApplyStarterPackDto(BuildPositionConflictManifest(), DryRun: true); + var response = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/apply", + dryRunRequest); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.Applied.Should().BeFalse(); + payload.HasConflicts.Should().BeTrue(); + payload.Conflicts.Should().Contain(conflict => + conflict.Code == "ColumnPositionConflict" && + conflict.Path == "$.columns[0].position" && + conflict.ExistingValue == "Existing" && + conflict.IncomingValue == "Backlog"); + } + + [Fact] + public async Task ApplyStarterPack_DryRun_ShouldIncludeSeedCardSkipAction_WhenSeedCardReferencesUnresolvableColumn() + { + var board = await CreateBoardAsync(); + var existingColumnResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/columns", + new CreateColumnDto(board.Id, "Existing", 0, null)); + existingColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var applyRequest = new ApplyStarterPackDto(BuildUnresolvableSeedCardManifest(), DryRun: true); + + var response = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/apply", + applyRequest); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.Applied.Should().BeFalse(); + payload.HasConflicts.Should().BeTrue(); + payload.HasBlockingConflicts.Should().BeTrue(); + payload.Conflicts.Should().Contain(conflict => + conflict.Code == "ColumnPositionConflict"); + payload.Conflicts.Should().Contain(conflict => + conflict.Code == "SeedCardColumnConflict" && + conflict.Severity == StarterPackConflictSeverity.Warning); + + payload.Actions.Count(action => + action.EntityType == "seedCard" && + action.Operation == "skip" && + action.Key == "Investigate intake @ Backlog") + .Should() + .Be(1); + } + + [Fact] + public async Task ApplyStarterPack_ShouldReturnConflict_WhenApplyHasConflicts() + { + var board = await CreateBoardAsync(); + var existingColumnResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/columns", + new CreateColumnDto(board.Id, "Existing", 0, null)); + existingColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var applyRequest = new ApplyStarterPackDto(BuildPositionConflictManifest(), DryRun: false); + var response = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/apply", + applyRequest); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.Applied.Should().BeFalse(); + payload.HasConflicts.Should().BeTrue(); + payload.Conflicts.Should().Contain(conflict => conflict.Code == "ColumnPositionConflict"); + + var columns = await _client.GetFromJsonAsync>($"/api/boards/{board.Id}/columns"); + columns.Should().NotBeNull(); + columns!.Should().ContainSingle(column => column.Name == "Existing"); + columns.Should().NotContain(column => column.Name == "Backlog"); + } + + [Fact] + public async Task ApplyStarterPack_ShouldReturnValidationError_WhenManifestIsEmpty() + { + var board = await CreateBoardAsync(); + + var response = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/apply", + new ApplyStarterPackDto(BuildEmptyManifest(), DryRun: false)); + + await ApiTestHarness.AssertErrorContractAsync(response, HttpStatusCode.BadRequest, "ValidationError"); + } + + [Fact] + public async Task ApplyStarterPack_ShouldReturnValidationError_WhenManifestContainsOnlyTemplates() + { + var board = await CreateBoardAsync(); + + var response = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/apply", + new ApplyStarterPackDto(BuildTemplateOnlyManifest(), DryRun: false)); + + await ApiTestHarness.AssertErrorContractAsync(response, HttpStatusCode.BadRequest, "ValidationError"); + } + + [Fact] + public async Task ApplyStarterPack_ShouldReturnConflict_WhenBoardContainsDuplicateNamesUsedByManifest() + { + var board = await CreateBoardAsync(); + + var firstLabelResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/labels", + new CreateLabelDto(board.Id, "priority-high", "#111111")); + firstLabelResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var secondLabelResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/labels", + new CreateLabelDto(board.Id, "Priority-High", "#222222")); + secondLabelResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var firstColumnResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/columns", + new CreateColumnDto(board.Id, "Backlog", 10, null)); + firstColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var secondColumnResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/columns", + new CreateColumnDto(board.Id, "backlog", 11, null)); + secondColumnResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var applyResponse = await _client.PostAsJsonAsync( + $"/api/boards/{board.Id}/starter-packs/apply", + new ApplyStarterPackDto(BuildCanonicalManifest(), DryRun: false)); + + applyResponse.StatusCode.Should().Be(HttpStatusCode.Conflict); + var payload = await applyResponse.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload!.Applied.Should().BeFalse(); + payload.HasConflicts.Should().BeTrue(); + payload.Conflicts.Should().Contain(conflict => + conflict.Code == "ExistingLabelNameConflict" && + conflict.Path == "$.board.labels"); + payload.Conflicts.Should().Contain(conflict => + conflict.Code == "ExistingColumnNameConflict" && + conflict.Path == "$.board.columns"); + } + + private async Task CreateBoardAsync() + { + await EnsureAuthenticatedAsync(); + return await ApiTestHarness.CreateBoardAsync(_client, "starter-pack-board", "Starter pack integration tests"); + } + + private async Task EnsureAuthenticatedAsync() + { + if (_isAuthenticated) + { + return; + } + + await ApiTestHarness.AuthenticateAsync(_client, "starter-pack-suite"); + _isAuthenticated = true; + } + + private static StarterPackManifestDto BuildCanonicalManifest() + { + return new StarterPackManifestDto + { + SchemaVersion = "1.0", + PackId = "engineering-onboarding", + DisplayName = "Engineering Onboarding", + Description = "Baseline board setup for engineering teams", + Compatibility = new StarterPackCompatibilityDto + { + MinTaskdeckVersion = "1.0.0", + MaxTaskdeckVersion = "2.0.0", + RequiredFeatures = ["boards", "labels"] + }, + Tags = ["starter", "engineering"], + Labels = + [ + new StarterPackLabelDto + { + Name = "priority-high", + Color = "#E85D5D", + Description = "High urgency" + } + ], + Columns = + [ + new StarterPackColumnDto + { + Name = "Backlog", + Position = 0 + }, + new StarterPackColumnDto + { + Name = "Done", + Position = 1 + } + ], + Templates = + [ + new StarterPackCardTemplateDto + { + TemplateId = "bug-report", + Title = "Bug Report", + Description = "Template for bug triage", + Checklist = ["Reproduction steps", "Expected behavior", "Actual behavior"] + } + ], + SeedCards = + [ + new StarterPackSeedCardDto + { + Title = "Set up sprint board", + Description = "Create initial sprint lanes", + ColumnName = "Backlog", + TemplateId = "bug-report", + Labels = ["priority-high"] + } + ] + }; + } + + private static StarterPackManifestDto BuildLabelOnlyManifest() + { + return new StarterPackManifestDto + { + SchemaVersion = "1.0", + PackId = "common-labels-core", + DisplayName = "Common Labels Core", + Description = "Reusable label taxonomy for existing boards", + Compatibility = new StarterPackCompatibilityDto + { + MinTaskdeckVersion = "1.0.0", + RequiredFeatures = ["boards", "labels"] + }, + Tags = ["starter", "labels"], + Labels = + [ + new StarterPackLabelDto + { + Name = "priority-high", + Color = "#E85D5D", + Description = "High urgency" + } + ], + Columns = [], + Templates = [], + SeedCards = [] + }; + } + + private static StarterPackManifestDto BuildPositionConflictManifest() + { + return new StarterPackManifestDto + { + SchemaVersion = "1.0", + PackId = "conflict-pack", + DisplayName = "Conflict Pack", + Compatibility = new StarterPackCompatibilityDto + { + MinTaskdeckVersion = "1.0.0", + RequiredFeatures = ["boards"] + }, + Tags = ["starter"], + Labels = [], + Columns = + [ + new StarterPackColumnDto + { + Name = "Backlog", + Position = 0 + } + ], + Templates = [], + SeedCards = [] + }; + } + + private static StarterPackManifestDto BuildEmptyManifest() + { + return new StarterPackManifestDto + { + SchemaVersion = "1.0", + PackId = "empty-pack", + DisplayName = "Empty Pack", + Compatibility = new StarterPackCompatibilityDto + { + MinTaskdeckVersion = "1.0.0", + RequiredFeatures = ["boards"] + }, + Tags = ["starter"], + Labels = [], + Columns = [], + Templates = [], + SeedCards = [] + }; + } + + private static StarterPackManifestDto BuildColumnOnlyManifest() + { + return new StarterPackManifestDto + { + SchemaVersion = "1.0", + PackId = "column-only-pack", + DisplayName = "Column Only Pack", + Compatibility = new StarterPackCompatibilityDto + { + MinTaskdeckVersion = "1.0.0", + RequiredFeatures = ["boards"] + }, + Tags = ["starter", "columns"], + Labels = [], + Columns = + [ + new StarterPackColumnDto + { + Name = "Backlog", + Position = 0 + }, + new StarterPackColumnDto + { + Name = "Done", + Position = 1 + } + ], + Templates = [], + SeedCards = [] + }; + } + + private static StarterPackManifestDto BuildTemplateOnlyManifest() + { + return new StarterPackManifestDto + { + SchemaVersion = "1.0", + PackId = "template-only-pack", + DisplayName = "Template Only Pack", + Compatibility = new StarterPackCompatibilityDto + { + MinTaskdeckVersion = "1.0.0", + RequiredFeatures = ["boards"] + }, + Tags = ["starter", "templates"], + Labels = [], + Columns = [], + Templates = + [ + new StarterPackCardTemplateDto + { + TemplateId = "bug-report", + Title = "Bug Report", + Description = "Template for bug triage", + Checklist = ["Reproduction steps"] + } + ], + SeedCards = [] + }; + } + + private static StarterPackManifestDto BuildUnresolvableSeedCardManifest() + { + return new StarterPackManifestDto + { + SchemaVersion = "1.0", + PackId = "seed-card-unresolvable-warning-pack", + DisplayName = "Seed Card Unresolvable Warning Pack", + Compatibility = new StarterPackCompatibilityDto + { + MinTaskdeckVersion = "1.0.0", + RequiredFeatures = ["boards"] + }, + Tags = ["starter"], + Labels = [], + Columns = + [ + new StarterPackColumnDto + { + Name = "Backlog", + Position = 0 + } + ], + Templates = [], + SeedCards = + [ + new StarterPackSeedCardDto + { + Title = "Investigate intake", + Description = "Investigate unresolvable column references", + ColumnName = "Backlog", + Labels = [] + } + ] + }; + } + +} diff --git a/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs b/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs index 33a229c01..5ca4a922c 100644 --- a/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs @@ -588,6 +588,8 @@ public Task> GetByRiskLevelAsync(RiskLevel riskL => throw new NotSupportedException(); public Task GetLatestByOperationTargetAsync(string targetType, string targetId, string actionType, ProposalSourceType sourceType, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GetPendingByOperationTargetAsync(string targetType, string targetId, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); public Task> GetTerminalByActionTypeAsync(string actionType, Guid? boardId, Guid userId, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } diff --git a/backend/tests/Taskdeck.Application.Tests/Services/ProposalConflictDetectorTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/ProposalConflictDetectorTests.cs new file mode 100644 index 000000000..24eda162c --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/ProposalConflictDetectorTests.cs @@ -0,0 +1,1211 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class ProposalConflictDetectorTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _proposalRepoMock; + private readonly Mock _cardRepoMock; + private readonly Mock _columnRepoMock; + private readonly Mock _commentRepoMock; + private readonly Mock _webhookRepoMock; + private readonly Mock _authServiceMock; + private readonly ProposalConflictDetector _detector; + + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _boardId = Guid.NewGuid(); + + public ProposalConflictDetectorTests() + { + _unitOfWorkMock = new Mock(); + _proposalRepoMock = new Mock(); + _cardRepoMock = new Mock(); + _columnRepoMock = new Mock(); + _commentRepoMock = new Mock(); + _webhookRepoMock = new Mock(); + _authServiceMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.AutomationProposals).Returns(_proposalRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Cards).Returns(_cardRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Columns).Returns(_columnRepoMock.Object); + _unitOfWorkMock.Setup(u => u.CardComments).Returns(_commentRepoMock.Object); + _unitOfWorkMock.Setup(u => u.OutboundWebhookSubscriptions).Returns(_webhookRepoMock.Object); + _authServiceMock.Setup(a => a.CanReadBoardAsync(_userId, It.IsAny())) + .ReturnsAsync(Result.Success(true)); + + _detector = new ProposalConflictDetector( + _unitOfWorkMock.Object, + _authServiceMock.Object); + } + + #region Authorization and Not Found + + [Fact] + public async Task DetectConflictsAsync_ProposalNotFound_ReturnsNotFound() + { + _proposalRepoMock.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((AutomationProposal?)null); + + var result = await _detector.DetectConflictsAsync(Guid.NewGuid(), _userId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + [Fact] + public async Task DetectConflictsAsync_OwnerHasAccess() + { + var proposal = CreateProposal(_userId, _boardId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DetectConflictsAsync_OwnerWithoutCurrentBoardAccess_ReturnsForbidden() + { + var proposal = CreateProposal(_userId, _boardId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _authServiceMock.Setup(a => a.CanReadBoardAsync(_userId, _boardId)) + .ReturnsAsync(Result.Success(false)); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + [Fact] + public async Task DetectConflictsAsync_NonOwnerWithBoardAccess_Succeeds() + { + var ownerId = Guid.NewGuid(); + var proposal = CreateProposal(ownerId, _boardId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _authServiceMock.Setup(a => a.CanReadBoardAsync(_userId, _boardId)) + .ReturnsAsync(Result.Success(true)); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DetectConflictsAsync_NonOwnerWithoutBoardAccess_ReturnsForbidden() + { + var ownerId = Guid.NewGuid(); + var proposal = CreateProposal(ownerId, _boardId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _authServiceMock.Setup(a => a.CanReadBoardAsync(_userId, _boardId)) + .ReturnsAsync(Result.Success(false)); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + [Fact] + public async Task DetectConflictsAsync_NonOwnerNoBoardScope_ReturnsForbidden() + { + var ownerId = Guid.NewGuid(); + var proposal = CreateProposal(ownerId, boardId: null); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + #endregion + + #region No Conflicts (Ok) + + [Fact] + public async Task DetectConflictsAsync_NoOperations_ReturnsOkRow() + { + var proposal = CreateProposal(_userId, _boardId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(1); + result.Value[0].Tone.Should().Be(ConflictTone.Ok); + result.Value[0].Key.Should().Be("status"); + result.Value[0].Value.Should().Be("No conflicts detected"); + } + + #endregion + + #region Warn: Stale Data + + [Fact] + public async Task DetectConflictsAsync_CardModifiedAfterProposal_ReturnsStaleWarning() + { + var cardId = Guid.NewGuid(); + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "move"); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + // Card updated after proposal was created + var card = new Card(_boardId, Guid.NewGuid(), "Test Card"); + // Touch the card to move its UpdatedAt ahead + card.Update(title: "Updated Title"); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + SetupEmptySecondaryChecks(proposal); + SetupNoDuplicateProposal(cardId); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Warn && r.Key == "stale-data"); + } + + [Fact] + public async Task DetectConflictsAsync_CardDeleted_ReturnsMissingTargetWarning() + { + var cardId = Guid.NewGuid(); + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update"); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync((Card?)null); + SetupEmptySecondaryChecks(proposal); + SetupNoDuplicateProposal(cardId); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Warn && r.Key == "missing-target"); + } + + #endregion + + #region Warn: WIP Limit + + [Fact] + public async Task DetectConflictsAsync_ColumnAtWipLimit_ReturnsWipWarning() + { + var columnId = Guid.NewGuid(); + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposalWithMoveOp(_userId, _boardId, cardId, columnId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + // Column with WIP limit of 2 and 2 cards already + var column = new Column(_boardId, "In Progress", 1, wipLimit: 2); + AddCardsToColumn(column, 2); + + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(columnId, It.IsAny())) + .ReturnsAsync(column); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + SetupEmptySecondaryChecks(proposal, cardId); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Warn && r.Key == "wip-limit"); + } + + [Fact] + public async Task DetectConflictsAsync_ColumnBelowWipLimit_NoWipWarning() + { + var columnId = Guid.NewGuid(); + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposalWithMoveOp(_userId, _boardId, cardId, columnId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + // Column with WIP limit of 5 and 2 cards + var column = new Column(_boardId, "In Progress", 1, wipLimit: 5); + AddCardsToColumn(column, 2); + + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(columnId, It.IsAny())) + .ReturnsAsync(column); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + SetupEmptySecondaryChecks(proposal, cardId); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "wip-limit"); + } + + [Fact] + public async Task DetectConflictsAsync_ProjectedCreatesExceedWipLimit_ReturnsWipWarning() + { + var columnId = Guid.NewGuid(); + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "create", "column", "{}", Guid.NewGuid().ToString(), columnId.ToString())); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 1, "create", "column", "{}", Guid.NewGuid().ToString(), columnId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + var column = new Column(_boardId, "In Progress", 1, wipLimit: 5); + AddCardsToColumn(column, 4); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(columnId, It.IsAny())) + .ReturnsAsync(column); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => + r.Tone == ConflictTone.Warn + && r.Key == "wip-limit" + && r.Value.Contains("(6/5)", StringComparison.Ordinal)); + } + + [Fact] + public async Task DetectConflictsAsync_MoveWithinSameColumn_DoesNotIncreaseProjectedWip() + { + var columnId = Guid.NewGuid(); + var cardId = Guid.NewGuid(); + var card = new Card(cardId, _boardId, columnId, "Same column move"); + var proposal = CreateProposalWithMoveOp(_userId, _boardId, cardId, columnId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + var column = new Column(_boardId, "In Progress", 1, wipLimit: 1); + AddCardsToColumn(column, 1); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(columnId, It.IsAny())) + .ReturnsAsync(column); + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + SetupEmptySecondaryChecks(proposal, cardId); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "wip-limit"); + } + + [Fact] + public async Task DetectConflictsAsync_CreateIntoMissingColumn_ReturnsMissingTargetColumnWarning() + { + var columnId = Guid.NewGuid(); + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "create", "column", "{}", Guid.NewGuid().ToString(), columnId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(columnId, It.IsAny())) + .ReturnsAsync((Column?)null); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => + r.Tone == ConflictTone.Warn + && r.Key == "missing-target-column"); + } + + [Fact] + public async Task DetectConflictsAsync_MoveOutAndIntoSameColumn_UsesNetProjectedWip() + { + var targetColumnId = Guid.NewGuid(); + var otherColumnId = Guid.NewGuid(); + var sourceColumnId = Guid.NewGuid(); + var leavingCardId = Guid.NewGuid(); + var enteringCardId = Guid.NewGuid(); + + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, + 0, + "move", + "card", + $"{{\"targetColumnId\":\"{otherColumnId}\"}}", + Guid.NewGuid().ToString(), + leavingCardId.ToString())); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, + 1, + "move", + "card", + $"{{\"targetColumnId\":\"{targetColumnId}\"}}", + Guid.NewGuid().ToString(), + enteringCardId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + var targetColumn = new Column(_boardId, "In Progress", 1, wipLimit: 5); + AddCardsToColumn(targetColumn, 5); + var otherColumn = new Column(_boardId, "Done", 2, wipLimit: 10); + + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(targetColumnId, It.IsAny())) + .ReturnsAsync(targetColumn); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(otherColumnId, It.IsAny())) + .ReturnsAsync(otherColumn); + + _cardRepoMock.Setup(r => r.GetByIdAsync(leavingCardId, It.IsAny())) + .ReturnsAsync(new Card(leavingCardId, _boardId, targetColumnId, "Leaving")); + _cardRepoMock.Setup(r => r.GetByIdAsync(enteringCardId, It.IsAny())) + .ReturnsAsync(new Card(enteringCardId, _boardId, sourceColumnId, "Entering")); + SetupEmptySecondaryChecks(proposal); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => + r.Key == "wip-limit" + && r.Value.Contains("In Progress", StringComparison.Ordinal)); + } + + #endregion + + #region Warn: Duplicate Pending Proposals + + [Fact] + public async Task DetectConflictsAsync_AnotherPendingProposalForSameCard_ReturnsDuplicateWarning() + { + var cardId = Guid.NewGuid(); + // Create card BEFORE proposal so card.UpdatedAt <= proposal.CreatedAt + var card = CreateCard(cardId); + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update"); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + + // Another pending proposal for the same card -- must be set up AFTER other mocks + // because SetupEmptySecondaryChecks would override with It.IsAny() + var otherProposal = CreateProposal(_userId, _boardId); + _proposalRepoMock.Setup(r => r.GetPendingByOperationTargetAsync( + "card", cardId.ToString(), It.IsAny())) + .ReturnsAsync(new List { otherProposal }); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Warn && r.Key == "duplicate-proposal"); + } + + [Fact] + public async Task DetectConflictsAsync_SameProposalFoundByTarget_NoDuplicateWarning() + { + var cardId = Guid.NewGuid(); + // Create card BEFORE proposal so card.UpdatedAt <= proposal.CreatedAt + var card = CreateCard(cardId); + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update"); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + // Same proposal returned by target query (not a duplicate) + _proposalRepoMock.Setup(r => r.GetPendingByOperationTargetAsync( + "card", cardId.ToString(), It.IsAny())) + .ReturnsAsync(new List { proposal }); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + SetupEmptySecondaryChecks(proposal); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "duplicate-proposal"); + } + + [Fact] + public async Task DetectConflictsAsync_CreateCardWithPreAssignedId_ChecksDuplicateProposals() + { + var cardId = Guid.NewGuid(); + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "create", "card", "{}", Guid.NewGuid().ToString(), cardId.ToString())); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var otherProposal = CreateProposal(_userId, _boardId); + _proposalRepoMock.Setup(r => r.GetPendingByOperationTargetAsync( + "card", cardId.ToString(), It.IsAny())) + .ReturnsAsync(new List { otherProposal }); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Warn && r.Key == "duplicate-proposal"); + result.Value.Should().NotContain(r => r.Key == "missing-target"); + result.Value.Should().NotContain(r => r.Key == "stale-data"); + } + + #endregion + + #region Warn: High Risk + + [Fact] + public async Task DetectConflictsAsync_HighRiskProposal_ReturnsHighRiskWarning() + { + var proposal = CreateProposal(_userId, _boardId, riskLevel: RiskLevel.High); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Warn && r.Key == "high-risk"); + } + + [Fact] + public async Task DetectConflictsAsync_CriticalRiskProposal_ReturnsHighRiskWarning() + { + var proposal = CreateProposal(_userId, _boardId, riskLevel: RiskLevel.Critical); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Warn && r.Key == "high-risk"); + } + + [Fact] + public async Task DetectConflictsAsync_LowRiskProposal_NoHighRiskWarning() + { + var proposal = CreateProposal(_userId, _boardId, riskLevel: RiskLevel.Low); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "high-risk"); + } + + [Fact] + public async Task DetectConflictsAsync_MediumRiskProposal_NoHighRiskWarning() + { + var proposal = CreateProposal(_userId, _boardId, riskLevel: RiskLevel.Medium); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "high-risk"); + } + + #endregion + + #region Info: Webhooks + + [Fact] + public async Task DetectConflictsAsync_ActiveWebhooks_ReturnsWebhookInfo() + { + var proposal = CreateProposal(_userId, _boardId); + var columnId = Guid.NewGuid(); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "update", "column", "{}", Guid.NewGuid().ToString(), columnId.ToString())); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + var webhook = new OutboundWebhookSubscription( + _boardId, _userId, "https://example.com/hook", "secret123", ["column.updated"]); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List { webhook }); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Info && r.Key == "webhooks"); + } + + [Fact] + public async Task DetectConflictsAsync_NonMatchingWebhooks_NoWebhookInfo() + { + var proposal = CreateProposal(_userId, _boardId); + var columnId = Guid.NewGuid(); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "update", "column", "{}", Guid.NewGuid().ToString(), columnId.ToString())); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + var webhook = new OutboundWebhookSubscription( + _boardId, _userId, "https://example.com/hook", "secret123", ["card.created"]); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List { webhook }); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "webhooks"); + } + + [Fact] + public async Task DetectConflictsAsync_ArchiveCardMatchesUpdatedWebhookFilter() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "archive"); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + SetupNoDuplicateProposal(cardId); + + var webhook = new OutboundWebhookSubscription( + _boardId, _userId, "https://example.com/hook", "secret123", ["card.updated"]); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List { webhook }); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Info && r.Key == "webhooks"); + } + + [Fact] + public async Task DetectConflictsAsync_NoOperationsWithActiveWebhooks_NoWebhookInfo() + { + var proposal = CreateProposal(_userId, _boardId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + var webhook = new OutboundWebhookSubscription( + _boardId, _userId, "https://example.com/hook", "secret123"); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List { webhook }); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "webhooks"); + } + + [Fact] + public async Task DetectConflictsAsync_NoWebhooks_NoWebhookInfo() + { + var proposal = CreateProposal(_userId, _boardId); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "webhooks"); + } + + [Fact] + public async Task DetectConflictsAsync_NoBoardId_NoWebhookCheck() + { + var proposal = CreateProposal(_userId, boardId: null); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + _webhookRepoMock.Verify( + r => r.GetActiveByBoardAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + #endregion + + #region Info: Active Comments + + [Fact] + public async Task DetectConflictsAsync_CardHasComments_ReturnsCommentsInfo() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update"); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(1); + + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Info && r.Key == "active-comments"); + } + + [Fact] + public async Task DetectConflictsAsync_CardHasNoComments_NoCommentsInfo() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update"); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "active-comments"); + } + + #endregion + + #region Info: Multiple Operations on Same Card + + [Fact] + public async Task DetectConflictsAsync_MultipleOpsOnSameCard_ReturnsMultiOpInfo() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "update", "card", "{}", Guid.NewGuid().ToString(), cardId.ToString())); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 1, "move", "card", "{}", Guid.NewGuid().ToString(), cardId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Info && r.Key == "multi-op"); + } + + [Fact] + public async Task DetectConflictsAsync_SingleOpPerCard_NoMultiOpInfo() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update"); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "multi-op"); + } + + #endregion + + #region Ok: Positive Signals + + [Fact] + public async Task DetectConflictsAsync_FreshCardWithOtherWarnings_ReturnsFreshDataOkRow() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + // High risk to trigger a warning, so positive signals are also emitted + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update", riskLevel: RiskLevel.High); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Ok && r.Key == "fresh-data"); + } + + [Fact] + public async Task DetectConflictsAsync_ColumnHasCapacity_ReturnsCapacityOkRow() + { + var columnId = Guid.NewGuid(); + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + // High risk to trigger a warning, so positive signals are also emitted + var proposal = CreateProposalWithMoveOp(_userId, _boardId, cardId, columnId, riskLevel: RiskLevel.High); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + // Column with WIP limit of 5 and 2 cards (has capacity) + var column = new Column(_boardId, "In Progress", 1, wipLimit: 5); + AddCardsToColumn(column, 2); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(columnId, It.IsAny())) + .ReturnsAsync(column); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + SetupEmptySecondaryChecks(proposal, cardId); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Tone == ConflictTone.Ok && r.Key == "capacity"); + } + + #endregion + + #region Sorting + + [Fact] + public async Task DetectConflictsAsync_MultipleRows_SortedByTone_WarnFirst() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + // High risk proposal targeting a card with comments + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update", riskLevel: RiskLevel.High); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(1); + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Count.Should().BeGreaterThan(1); + + // Warn should come before Info, Info before Ok + var tones = result.Value.Select(r => r.Tone).ToList(); + tones.Should().BeInAscendingOrder(); + } + + #endregion + + #region Combination + + [Fact] + public async Task DetectConflictsAsync_MultipleConflictsDetected_ReturnsAllRows() + { + var cardId = Guid.NewGuid(); + // Create card BEFORE proposal so card.UpdatedAt <= proposal.CreatedAt + var card = CreateCard(cardId); + // High risk + card with comments + webhooks + var proposal = CreateProposalWithCardOp(_userId, _boardId, cardId, "update", riskLevel: RiskLevel.Critical); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + + // Has comments + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(1); + + // Has webhooks + var webhook = new OutboundWebhookSubscription( + _boardId, _userId, "https://example.com/hook", "secret123"); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List { webhook }); + + SetupNoDuplicateProposal(cardId); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + // Should have: high-risk (warn), active-comments (info), webhooks (info), fresh-data (ok) + result.Value.Should().Contain(r => r.Key == "high-risk"); + result.Value.Should().Contain(r => r.Key == "active-comments"); + result.Value.Should().Contain(r => r.Key == "webhooks"); + result.Value.Should().Contain(r => r.Key == "fresh-data"); + } + + #endregion + + #region Expired Proposal Handling + + [Fact] + public async Task DetectConflictsAsync_ExpiredProposal_StillReturnsConflicts() + { + // Expired proposals can still have their conflicts inspected + var proposal = CreateProposal(_userId, _boardId, expiryMinutes: 1); + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + SetupNoConflicts(); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + } + + #endregion + + #region Edge Cases + + [Fact] + public async Task DetectConflictsAsync_OperationWithInvalidTargetId_SkipsGracefully() + { + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "update", "card", "{}", Guid.NewGuid().ToString(), "not-a-guid")); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DetectConflictsAsync_OperationWithNullTargetId_SkipsGracefully() + { + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "create", "card", "{}", Guid.NewGuid().ToString(), targetId: null)); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DetectConflictsAsync_MalformedParametersJson_SkipsColumnParsing() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "move", "card", "not-json{", Guid.NewGuid().ToString(), cardId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DetectConflictsAsync_CreateCardWithPreAssignedId_NoStaleOrMissingWarning() + { + var cardId = Guid.NewGuid(); + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "create", "card", "{}", Guid.NewGuid().ToString(), cardId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + SetupNoDuplicateProposal(cardId); + + // Card does NOT exist yet (create operation) -- should NOT produce missing-target warning + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync((Card?)null); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotContain(r => r.Key == "missing-target"); + result.Value.Should().NotContain(r => r.Key == "stale-data"); + } + + [Fact] + public async Task DetectConflictsAsync_ColumnOnlyOperation_DoesNotRunWipCapacityChecks() + { + var columnId = Guid.NewGuid(); + var proposal = CreateProposal(_userId, _boardId, riskLevel: RiskLevel.High); + // Column-targeted operation with minimal parameters (domain requires non-empty) + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "archive", "column", "{}", Guid.NewGuid().ToString(), columnId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + + var column = new Column(_boardId, "Done", 3, wipLimit: 10); + _columnRepoMock.Setup(r => r.GetByIdWithCardsAsync(columnId, It.IsAny())) + .ReturnsAsync(column); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Contain(r => r.Key == "high-risk"); + result.Value.Should().NotContain(r => r.Key == "capacity"); + result.Value.Should().NotContain(r => r.Key == "wip-limit"); + _columnRepoMock.Verify( + r => r.GetByIdWithCardsAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task DetectConflictsAsync_NonStringColumnIdInJson_DoesNotThrow() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposal(_userId, _boardId); + // columnId is a number, not a string -- should not throw InvalidOperationException + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "move", "card", "{\"columnId\": 12345}", Guid.NewGuid().ToString(), cardId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task DetectConflictsAsync_NonObjectParametersJson_DoesNotThrow() + { + var cardId = Guid.NewGuid(); + var card = CreateCard(cardId); + var proposal = CreateProposal(_userId, _boardId); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "move", "card", "[]", Guid.NewGuid().ToString(), cardId.ToString())); + + _proposalRepoMock.Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + _commentRepoMock.Setup(r => r.CountByCardIdAsync(cardId, It.IsAny())) + .ReturnsAsync(0); + SetupNoDuplicateProposal(cardId); + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(_boardId, It.IsAny())) + .ReturnsAsync(new List()); + + var result = await _detector.DetectConflictsAsync(proposal.Id, _userId); + + result.IsSuccess.Should().BeTrue(); + } + + #endregion + + #region Helpers + + private AutomationProposal CreateProposal( + Guid userId, + Guid? boardId, + RiskLevel riskLevel = RiskLevel.Low, + int expiryMinutes = 1440) + { + return new AutomationProposal( + ProposalSourceType.Chat, + userId, + "Test proposal", + riskLevel, + Guid.NewGuid().ToString(), + boardId, + expiryMinutes: expiryMinutes); + } + + private AutomationProposal CreateProposalWithCardOp( + Guid userId, + Guid boardId, + Guid cardId, + string actionType, + RiskLevel riskLevel = RiskLevel.Low) + { + var proposal = CreateProposal(userId, boardId, riskLevel); + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, actionType, "card", "{}", Guid.NewGuid().ToString(), cardId.ToString())); + return proposal; + } + + private AutomationProposal CreateProposalWithMoveOp( + Guid userId, + Guid boardId, + Guid cardId, + Guid targetColumnId, + RiskLevel riskLevel = RiskLevel.Low) + { + var proposal = CreateProposal(userId, boardId, riskLevel); + var parameters = $"{{\"columnId\":\"{targetColumnId}\"}}"; + proposal.AddOperation(new AutomationProposalOperation( + proposal.Id, 0, "move", "card", parameters, Guid.NewGuid().ToString(), cardId.ToString())); + return proposal; + } + + /// + /// Creates a card with a known ID. Note: When used after a proposal is + /// created, the card's UpdatedAt will be slightly after the proposal's + /// CreatedAt (due to DateTimeOffset.UtcNow in both constructors). + /// For "fresh card" semantics, create the card BEFORE the proposal. + /// + private Card CreateCard(Guid cardId, string title = "Test Card") + { + return new Card(cardId, _boardId, Guid.NewGuid(), title); + } + + private void SetupNoConflicts() + { + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + } + + private void SetupEmptySecondaryChecks(AutomationProposal proposal, Guid? specificCardId = null) + { + _webhookRepoMock.Setup(r => r.GetActiveByBoardAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + _commentRepoMock.Setup(r => r.CountByCardIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(0); + + if (specificCardId.HasValue) + { + SetupNoDuplicateProposal(specificCardId.Value); + } + else + { + // Setup for any card ID + _proposalRepoMock.Setup(r => r.GetPendingByOperationTargetAsync( + "card", It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + } + } + + private void SetupNoDuplicateProposal(Guid cardId) + { + _proposalRepoMock.Setup(r => r.GetPendingByOperationTargetAsync( + "card", cardId.ToString(), It.IsAny())) + .ReturnsAsync(new List()); + } + + private void SetupCardForMove(AutomationProposal proposal, Guid? specificCardId = null) + { + // For move operations, we still need the card check for stale data + if (specificCardId.HasValue) + { + var card = CreateCard(specificCardId.Value); + _cardRepoMock.Setup(r => r.GetByIdAsync(specificCardId.Value, It.IsAny())) + .ReturnsAsync(card); + } + else + { + // Setup for any card in the proposal operations + foreach (var op in proposal.Operations) + { + if (op.TargetType.Equals("card", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(op.TargetId) + && Guid.TryParse(op.TargetId, out var cardId)) + { + var card = CreateCard(cardId); + _cardRepoMock.Setup(r => r.GetByIdAsync(cardId, It.IsAny())) + .ReturnsAsync(card); + } + } + } + } + + private static void AddCardsToColumn(Column column, int count) + { + for (var i = 0; i < count; i++) + { + var card = new Card(column.BoardId, column.Id, $"Card {i}", position: i); + column.AddCard(card); + } + } + + #endregion +} diff --git a/backend/tests/Taskdeck.Domain.Tests/Entities/ConflictRowTests.cs b/backend/tests/Taskdeck.Domain.Tests/Entities/ConflictRowTests.cs new file mode 100644 index 000000000..90d769b8b --- /dev/null +++ b/backend/tests/Taskdeck.Domain.Tests/Entities/ConflictRowTests.cs @@ -0,0 +1,119 @@ +using FluentAssertions; +using Taskdeck.Domain.Entities; +using Xunit; + +namespace Taskdeck.Domain.Tests.Entities; + +public class ConflictRowTests +{ + [Theory] + [InlineData(ConflictTone.Warn)] + [InlineData(ConflictTone.Info)] + [InlineData(ConflictTone.Ok)] + public void Constructor_ShouldCreateRow_WithValidData(ConflictTone tone) + { + var row = new ConflictRow(tone, "test-key", "test value"); + + row.Tone.Should().Be(tone); + row.Key.Should().Be("test-key"); + row.Value.Should().Be("test value"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void Constructor_ShouldThrow_WhenKeyIsBlank(string key) + { + var act = () => new ConflictRow(ConflictTone.Warn, key, "value"); + + act.Should().Throw() + .WithMessage("Conflict row key cannot be empty.*"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void Constructor_ShouldThrow_WhenValueIsBlank(string value) + { + var act = () => new ConflictRow(ConflictTone.Info, "key", value); + + act.Should().Throw() + .WithMessage("Conflict row value cannot be empty.*"); + } + + [Fact] + public void Constructor_ShouldThrow_WhenToneIsInvalid() + { + var act = () => new ConflictRow((ConflictTone)99, "key", "value"); + + act.Should().Throw() + .WithMessage("Invalid conflict tone*"); + } + + [Fact] + public void Equals_SameValues_ShouldBeEqual() + { + var row1 = new ConflictRow(ConflictTone.Warn, "stale-data", "Card was modified"); + var row2 = new ConflictRow(ConflictTone.Warn, "stale-data", "Card was modified"); + + row1.Should().Be(row2); + row1.Equals(row2).Should().BeTrue(); + (row1.GetHashCode() == row2.GetHashCode()).Should().BeTrue(); + } + + [Fact] + public void Equals_DifferentTone_ShouldNotBeEqual() + { + var row1 = new ConflictRow(ConflictTone.Warn, "key", "value"); + var row2 = new ConflictRow(ConflictTone.Info, "key", "value"); + + row1.Should().NotBe(row2); + } + + [Fact] + public void Equals_DifferentKey_ShouldNotBeEqual() + { + var row1 = new ConflictRow(ConflictTone.Ok, "key-a", "value"); + var row2 = new ConflictRow(ConflictTone.Ok, "key-b", "value"); + + row1.Should().NotBe(row2); + } + + [Fact] + public void Equals_DifferentValue_ShouldNotBeEqual() + { + var row1 = new ConflictRow(ConflictTone.Info, "key", "value A"); + var row2 = new ConflictRow(ConflictTone.Info, "key", "value B"); + + row1.Should().NotBe(row2); + } + + [Fact] + public void Equals_Null_ShouldNotBeEqual() + { + var row = new ConflictRow(ConflictTone.Ok, "key", "value"); + + row.Equals(null).Should().BeFalse(); + } + + [Fact] + public void ToString_ShouldFormatCorrectly() + { + var row = new ConflictRow(ConflictTone.Warn, "stale-data", "Card was modified"); + + row.ToString().Should().Be("[Warn] stale-data: Card was modified"); + } + + [Theory] + [InlineData(ConflictTone.Warn)] + [InlineData(ConflictTone.Info)] + [InlineData(ConflictTone.Ok)] + public void Constructor_ShouldAcceptAllTones(ConflictTone tone) + { + var row = new ConflictRow(tone, "test", "test"); + + row.Tone.Should().Be(tone); + } +} diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 47ada85bb..3ccef8026 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -1,377 +1,377 @@ -# Taskdeck User Manual - -If you are new to Taskdeck, read [START_HERE.md](START_HERE.md) first. -This manual is the reference for the current shipped product shape. - -## What Taskdeck Is - -Taskdeck is a capture-first, review-first execution workspace. -Its main loop is: - -1. capture something quickly -2. shape it into a proposed change -3. review the proposal -4. apply it explicitly -5. continue the work on a board -6. inspect history or notifications only when you need evidence - -Current product shape: - -- the default route is now `Home` -- `Today` is the daily agenda surface -- `Review` is the normal automation surface -- `Boards` remains the visible work surface -- advanced or operator tools are shipped, but they are not the normal first-run path - -## Current Golden Path - -The fastest current path to value is: - -1. land on `Home` -2. create a board from setup if you do not have one yet -3. capture rough input into quick capture or `Inbox` -4. start triage -5. open `Review` -6. review, approve, and execute the proposal -7. return to the board and work the cards - -That is the current product loop. - -`Today` supports the same loop by making the next daily action visible when you already have work underway. - -## Navigation By Workspace Mode - -Workspace modes are display preferences, not permission boundaries. - -Guided mode keeps the normal loop prominent: - -- primary: `Home`, `Today`, `Review`, `Boards`, `Inbox` -- secondary: `Notifications`, `Chat`, `Settings`, `Preferences` -- advanced feature-flagged surfaces that can still appear only after explicit toggles: `Activity`, `Ops`, `Access`, `Archive` - -Workbench mode shows all shipped workspace surfaces in the main nav without requiring feature-flag toggles: - -- primary: `Home`, `Today`, `Review`, `Boards`, `Inbox`, `Notifications`, `Chat`, `Activity`, `Ops`, `Settings`, `Preferences`, `Access`, `Archive` - -Agent mode currently ships the same core pages as guided mode, while the later agent-specific surfaces are still staged work: - -- primary: `Home`, `Today`, `Review`, `Boards`, `Inbox` -- secondary: `Notifications`, `Chat`, `Settings`, `Preferences` -- advanced feature-flagged surfaces that can still appear only after explicit toggles: `Activity`, `Ops`, `Access`, `Archive` - -Important truth: - -- `Agent` mode exists as a workspace preference today -- dedicated `Agents`, `Runs`, `Knowledge`, and `Integrations` routes do not ship yet - -## Surface Reference - -### Home - -What it is for: - -- resetting the loop -- seeing the current workload clearly -- replaying onboarding or setup when the path is unclear - -Use it when: - -- you first sign in -- you are not sure where to begin -- you want recent boards and recommended actions in one place - -### Today - -What it is for: - -- deciding the next daily action -- reviewing proposals before diving into board work -- surfacing overdue, due-today, and blocked cards - -Use it when: - -- you already have work in motion -- you want the daily agenda instead of the broad reset view - -### Boards - -What it is for: - -- visible execution -- editing cards and columns -- comments, labels, due dates, and blockers - -Use it when: - -- work is ready to be acted on -- you need to move cards forward or collaborate on details - -Product-language note: - -- the route label is still `Boards` -- the docs use `project` as the higher-level mental model when helpful - -### Inbox - -What it is for: - -- messy intake -- storing work before it is structured enough for the board -- turning captures into reviewable proposals - -Use it when: - -- the input is rough -- you need to save context now and decide shape later - -### Review - -What it is for: - -- approving or rejecting proposed changes -- keeping automation review-first and explicit - -Use it when: - -- triage has produced a proposal -- `Home` or `Today` says review work is waiting - -Compatibility note: - -- `Review` is the current user-facing path for the older proposals route -- legacy automation paths still redirect for compatibility - -### Notifications - -What it is for: - -- mentions -- proposal outcomes -- follow-up signals that matter to a user directly - -### Settings And Preferences - -Settings covers: - -- profile details -- notification preferences -- feature-flagged advanced surfaces - -Use preferences when: - -- you need to tune notification behavior or workspace posture - -### Access - -Use `Access` when: - -- you are managing board membership and roles - -This is an advanced management surface, not part of the normal capture path. - -### Archive - -Use `Archive` when: - -- you need to restore or inspect archived boards - -### Chat - -Use `Chat` when: - -- you want board-scoped conversational help -- you intentionally want a more manual operator-style automation flow - -### Activity - -Use `Activity` when: - -- you need history, provenance, or audit-style context - -### Ops - -Use `Ops` when: - -- you are diagnosing the system -- you need logs, endpoint exploration, or CLI tooling - -## Boards, Cards, And Starter Packs - -Boards: - -- contain columns, cards, labels, comments, and board settings -- can be archived and restored -- are where work should feel visible and actionable - -Cards support: - -- title and description -- due date -- labels -- blocked state and blocked reason -- threaded comments and mentions - -Starter packs: - -- scaffold columns, labels, and optional seed cards -- are safe to reapply because apply behavior is idempotent and conflict-aware -- currently support fast-start shapes such as `Engineering sprint`, `Support triage`, and `Content calendar` - -## Inbox, Triage, And Review - -Use `Inbox` for: - -- notes -- bugs -- follow-ups -- rough plans -- ideas you do not want to lose - -Inbox actions: - -- `Ignore` for noise or duplicates -- `Start Triage` to request a reviewed proposal - -Proposal review: - -- happens in `Review` -- is the primary trust boundary for board mutation -- should answer what changes, where, and why - -Current review model: - -1. proposal is generated -2. user reviews operations and summary -3. user approves or rejects -4. user executes explicitly - -## Daily Rhythm - -Morning: - -- open `Home` -- open `Today` -- review pending proposals before diving into card work - -During work: - -- capture follow-ups immediately instead of holding them in your head -- use comments to preserve reasoning on cards -- return to the board when proposals create or update work - -End of day: - -- move cards forward honestly -- capture loose ends into `Inbox` -- avoid leaving important context only in local notes or memory - -## Normal User Surfaces vs Advanced Surfaces - -Normal user surfaces: - -- `Home` -- `Today` -- `Inbox` -- `Review` -- `Boards` - -Advanced or operator surfaces: - -- `Chat` -- `Activity` -- `Notifications` -- `Ops` -- `Access` -- `Archive` - -Rule of thumb: - -- if you are new, stay in the normal user surfaces until the loop is clear -- only drop into advanced surfaces when you specifically need diagnostics, evidence, or manual control - -## Workflow Guides And Help - -Use these docs together: - -- [product/FIRST_RUN_WORKFLOWS.md](product/FIRST_RUN_WORKFLOWS.md) - - concise step-by-step guides for first-run and daily flows -- [product/HELP_AND_FAQ.md](product/HELP_AND_FAQ.md) - - page-level help, empty-state recovery, and common confusion points -- [manual/README.md](manual/README.md) - - manual structure and in-app help mapping - -## Manual Chapters - -This user manual is chaptered under `docs/manual`. The primary chapters are: - -- [manual/01_start_here.md](manual/01_start_here.md) - - product framing, first-value path, and glossary -- [manual/02_home_and_today.md](manual/02_home_and_today.md) - - `Home`, `Today`, and the daily rhythm -- [manual/03_projects_and_cards.md](manual/03_projects_and_cards.md) - - boards, cards, starter packs, and execution flow -- [manual/04_inbox_and_review.md](manual/04_inbox_and_review.md) - - capture, triage, review, and trust model guidance -- [manual/05_advanced_automation.md](manual/05_advanced_automation.md) - - advanced/operator surfaces such as `Chat`, `Queue`, `Ops`, and `Archive` -- [manual/06_agents.md](manual/06_agents.md) - - future `Agents` and `Runs` placeholder guidance -- [manual/07_integrations_and_knowledge.md](manual/07_integrations_and_knowledge.md) - - future `Integrations` and `Knowledge` placeholder guidance -- [manual/08_recipes.md](manual/08_recipes.md) - - short repeatable workflows -- [manual/09_troubleshooting.md](manual/09_troubleshooting.md) - - common questions, empty states, and recovery paths - -## Demo And Testing Workflows - -From `frontend/taskdeck-web`: - -- `npm run demo:seed` seeds a reusable baseline workspace -- `npm run demo:run -- --list` lists scenarios -- `npm run demo:run -- engineering-sprint` runs one scenario -- `npm run demo:autopilot -- --turns 5 --brain heuristic` simulates activity -- `npm run demo:director:smoke` runs the deterministic smoke or demo regression path - -For direct API walkthroughs: - -- use `demo/http/taskdeck-demo.http` with the VS Code REST Client - -For the full demo or operator path: - -- see [product/DEMO_PLAYBOOK.md](product/DEMO_PLAYBOOK.md) -- see [product/SCENARIOS.md](product/SCENARIOS.md) - -## Troubleshooting Basics - -If you do not know where to start: - -- go back to `Home` -- replay setup if needed -- use `Today` for the next daily action -- use `Inbox` if the work is still a note -- use `Review` if the work is waiting for approval - -If `Review` is empty: - -- triage something from `Inbox` first - -If `Boards` is empty: - -- create a board or use the setup flow from `Home` or `Today` - -If `Notifications` or `Activity` is empty: - -- those surfaces usually need real work history before they become useful - -If `Queue` or `Ops` feels too technical: - -- that is expected for ordinary daily use -- return to `Inbox`, `Review`, or the board - -## Current Constraints - -- `Agent` mode exists, but dedicated `Agents`, `Runs`, `Knowledge`, and `Integrations` routes are still future work -- some advanced flows still expose more system detail than a novice-ready product should -- automation parsing remains pattern-based and board-centric -- review-first behavior is intentional; destructive autonomy is out of scope +# Taskdeck User Manual + +If you are new to Taskdeck, read [START_HERE.md](START_HERE.md) first. +This manual is the reference for the current shipped product shape. + +## What Taskdeck Is + +Taskdeck is a capture-first, review-first execution workspace. +Its main loop is: + +1. capture something quickly +2. shape it into a proposed change +3. review the proposal +4. apply it explicitly +5. continue the work on a board +6. inspect history or notifications only when you need evidence + +Current product shape: + +- the default route is now `Home` +- `Today` is the daily agenda surface +- `Review` is the normal automation surface +- `Boards` remains the visible work surface +- advanced or operator tools are shipped, but they are not the normal first-run path + +## Current Golden Path + +The fastest current path to value is: + +1. land on `Home` +2. create a board from setup if you do not have one yet +3. capture rough input into quick capture or `Inbox` +4. start triage +5. open `Review` +6. review, approve, and execute the proposal +7. return to the board and work the cards + +That is the current product loop. + +`Today` supports the same loop by making the next daily action visible when you already have work underway. + +## Navigation By Workspace Mode + +Workspace modes are display preferences, not permission boundaries. + +Guided mode keeps the normal loop prominent: + +- primary: `Home`, `Today`, `Review`, `Boards`, `Inbox` +- secondary: `Notifications`, `Chat`, `Settings`, `Preferences` +- advanced feature-flagged surfaces that can still appear only after explicit toggles: `Activity`, `Ops`, `Access`, `Archive` + +Workbench mode shows all shipped workspace surfaces in the main nav without requiring feature-flag toggles: + +- primary: `Home`, `Today`, `Review`, `Boards`, `Inbox`, `Notifications`, `Chat`, `Activity`, `Ops`, `Settings`, `Preferences`, `Access`, `Archive` + +Agent mode currently ships the same core pages as guided mode, while the later agent-specific surfaces are still staged work: + +- primary: `Home`, `Today`, `Review`, `Boards`, `Inbox` +- secondary: `Notifications`, `Chat`, `Settings`, `Preferences` +- advanced feature-flagged surfaces that can still appear only after explicit toggles: `Activity`, `Ops`, `Access`, `Archive` + +Important truth: + +- `Agent` mode exists as a workspace preference today +- dedicated `Agents`, `Runs`, `Knowledge`, and `Integrations` routes do not ship yet + +## Surface Reference + +### Home + +What it is for: + +- resetting the loop +- seeing the current workload clearly +- replaying onboarding or setup when the path is unclear + +Use it when: + +- you first sign in +- you are not sure where to begin +- you want recent boards and recommended actions in one place + +### Today + +What it is for: + +- deciding the next daily action +- reviewing proposals before diving into board work +- surfacing overdue, due-today, and blocked cards + +Use it when: + +- you already have work in motion +- you want the daily agenda instead of the broad reset view + +### Boards + +What it is for: + +- visible execution +- editing cards and columns +- comments, labels, due dates, and blockers + +Use it when: + +- work is ready to be acted on +- you need to move cards forward or collaborate on details + +Product-language note: + +- the route label is still `Boards` +- the docs use `project` as the higher-level mental model when helpful + +### Inbox + +What it is for: + +- messy intake +- storing work before it is structured enough for the board +- turning captures into reviewable proposals + +Use it when: + +- the input is rough +- you need to save context now and decide shape later + +### Review + +What it is for: + +- approving or rejecting proposed changes +- keeping automation review-first and explicit + +Use it when: + +- triage has produced a proposal +- `Home` or `Today` says review work is waiting + +Compatibility note: + +- `Review` is the current user-facing path for the older proposals route +- legacy automation paths still redirect for compatibility + +### Notifications + +What it is for: + +- mentions +- proposal outcomes +- follow-up signals that matter to a user directly + +### Settings And Preferences + +Settings covers: + +- profile details +- notification preferences +- feature-flagged advanced surfaces + +Use preferences when: + +- you need to tune notification behavior or workspace posture + +### Access + +Use `Access` when: + +- you are managing board membership and roles + +This is an advanced management surface, not part of the normal capture path. + +### Archive + +Use `Archive` when: + +- you need to restore or inspect archived boards + +### Chat + +Use `Chat` when: + +- you want board-scoped conversational help +- you intentionally want a more manual operator-style automation flow + +### Activity + +Use `Activity` when: + +- you need history, provenance, or audit-style context + +### Ops + +Use `Ops` when: + +- you are diagnosing the system +- you need logs, endpoint exploration, or CLI tooling + +## Boards, Cards, And Starter Packs + +Boards: + +- contain columns, cards, labels, comments, and board settings +- can be archived and restored +- are where work should feel visible and actionable + +Cards support: + +- title and description +- due date +- labels +- blocked state and blocked reason +- threaded comments and mentions + +Starter packs: + +- scaffold columns, labels, and optional seed cards +- are safe to reapply because apply behavior is idempotent and conflict-aware +- currently support fast-start shapes such as `Engineering sprint`, `Support triage`, and `Content calendar` + +## Inbox, Triage, And Review + +Use `Inbox` for: + +- notes +- bugs +- follow-ups +- rough plans +- ideas you do not want to lose + +Inbox actions: + +- `Ignore` for noise or duplicates +- `Start Triage` to request a reviewed proposal + +Proposal review: + +- happens in `Review` +- is the primary trust boundary for board mutation +- should answer what changes, where, and why + +Current review model: + +1. proposal is generated +2. user reviews operations and summary +3. user approves or rejects +4. user executes explicitly + +## Daily Rhythm + +Morning: + +- open `Home` +- open `Today` +- review pending proposals before diving into card work + +During work: + +- capture follow-ups immediately instead of holding them in your head +- use comments to preserve reasoning on cards +- return to the board when proposals create or update work + +End of day: + +- move cards forward honestly +- capture loose ends into `Inbox` +- avoid leaving important context only in local notes or memory + +## Normal User Surfaces vs Advanced Surfaces + +Normal user surfaces: + +- `Home` +- `Today` +- `Inbox` +- `Review` +- `Boards` + +Advanced or operator surfaces: + +- `Chat` +- `Activity` +- `Notifications` +- `Ops` +- `Access` +- `Archive` + +Rule of thumb: + +- if you are new, stay in the normal user surfaces until the loop is clear +- only drop into advanced surfaces when you specifically need diagnostics, evidence, or manual control + +## Workflow Guides And Help + +Use these docs together: + +- [product/FIRST_RUN_WORKFLOWS.md](product/FIRST_RUN_WORKFLOWS.md) + - concise step-by-step guides for first-run and daily flows +- [product/HELP_AND_FAQ.md](product/HELP_AND_FAQ.md) + - page-level help, empty-state recovery, and common confusion points +- [manual/README.md](manual/README.md) + - manual structure and in-app help mapping + +## Manual Chapters + +This user manual is chaptered under `docs/manual`. The primary chapters are: + +- [manual/01_start_here.md](manual/01_start_here.md) + - product framing, first-value path, and glossary +- [manual/02_home_and_today.md](manual/02_home_and_today.md) + - `Home`, `Today`, and the daily rhythm +- [manual/03_projects_and_cards.md](manual/03_projects_and_cards.md) + - boards, cards, starter packs, and execution flow +- [manual/04_inbox_and_review.md](manual/04_inbox_and_review.md) + - capture, triage, review, and trust model guidance +- [manual/05_advanced_automation.md](manual/05_advanced_automation.md) + - advanced/operator surfaces such as `Chat`, `Queue`, `Ops`, and `Archive` +- [manual/06_agents.md](manual/06_agents.md) + - future `Agents` and `Runs` placeholder guidance +- [manual/07_integrations_and_knowledge.md](manual/07_integrations_and_knowledge.md) + - future `Integrations` and `Knowledge` placeholder guidance +- [manual/08_recipes.md](manual/08_recipes.md) + - short repeatable workflows +- [manual/09_troubleshooting.md](manual/09_troubleshooting.md) + - common questions, empty states, and recovery paths + +## Demo And Testing Workflows + +From `frontend/taskdeck-web`: + +- `npm run demo:seed` seeds a reusable baseline workspace +- `npm run demo:run -- --list` lists scenarios +- `npm run demo:run -- engineering-sprint` runs one scenario +- `npm run demo:autopilot -- --turns 5 --brain heuristic` simulates activity +- `npm run demo:director:smoke` runs the deterministic smoke or demo regression path + +For direct API walkthroughs: + +- use `demo/http/taskdeck-demo.http` with the VS Code REST Client + +For the full demo or operator path: + +- see [product/DEMO_PLAYBOOK.md](product/DEMO_PLAYBOOK.md) +- see [product/SCENARIOS.md](product/SCENARIOS.md) + +## Troubleshooting Basics + +If you do not know where to start: + +- go back to `Home` +- replay setup if needed +- use `Today` for the next daily action +- use `Inbox` if the work is still a note +- use `Review` if the work is waiting for approval + +If `Review` is empty: + +- triage something from `Inbox` first + +If `Boards` is empty: + +- create a board or use the setup flow from `Home` or `Today` + +If `Notifications` or `Activity` is empty: + +- those surfaces usually need real work history before they become useful + +If `Queue` or `Ops` feels too technical: + +- that is expected for ordinary daily use +- return to `Inbox`, `Review`, or the board + +## Current Constraints + +- `Agent` mode exists, but dedicated `Agents`, `Runs`, `Knowledge`, and `Integrations` routes are still future work +- some advanced flows still expose more system detail than a novice-ready product should +- automation parsing remains pattern-based and board-centric +- review-first behavior is intentional; destructive autonomy is out of scope diff --git a/docs/manual/01_start_here.md b/docs/manual/01_start_here.md index 1357b0b3b..7989ae7bf 100644 --- a/docs/manual/01_start_here.md +++ b/docs/manual/01_start_here.md @@ -1,107 +1,107 @@ -# Manual Chapter 01: Start Here - -This chapter explains what Taskdeck is for and how to get to first value quickly. - -## What Taskdeck Is For - -Taskdeck helps you move from messy input to explicit, reviewable work. - -The core promise is simple: - -1. capture something fast -2. shape it into a proposal -3. review the change before it lands -4. continue the real work on a board - -Taskdeck is not trying to hide automation behind silent mutation. The review step is part of the product, not a temporary limitation. - -## The Fastest Useful Path - -1. open `Home` -2. create or open a useful board -3. put one note or task into `Inbox` -4. run `Start Triage` -5. open `Review` -6. approve and execute one proposal -7. continue from the board - -If you only do one thing on your first run, do that loop once. - -## Glossary - -`Home` -- the default landing page and reset surface - -`Today` -- the agenda view for review, triage, and due or blocked board work - -`Inbox` -- where rough notes, pasted text, follow-ups, and transcripts wait to be shaped - -`Review` -- the trust gate where proposals stop before they touch a board - -`Boards` -- the shipped label for workspaces where cards live - -`Projects` -- the product-facing term that may appear in docs or later UI language for the same board concept - -`Queue` -- an advanced manual automation input surface, not the normal first-run path - -`Agents` -- future-facing supervised assistant surfaces that are not part of the shipped shell yet - -## What Is Shipped Today - -Shipped and part of the normal path: - -- `Home` -- `Today` -- `Inbox` -- `Review` -- `Boards` - -Shipped but usually advanced: - -- `Chat` -- `Activity` -- `Notifications` -- `Ops` -- `Access` -- `Archive` - -Planned, not shipped: - -- `Agents` -- `Runs` -- `Knowledge` -- `Integrations` - -## If You Want A Richer First Run - -From `frontend/taskdeck-web`: - -```bash -npm run demo:seed -``` - -Use the seeded workspace when: - -- you want real examples immediately -- you are evaluating the product rather than starting empty -- you need a walkthrough or training baseline - -## Common Mistakes - -- starting from `Queue` instead of `Inbox` -- thinking `Review` is optional -- expecting `Agents` or `Integrations` to be current top-level workspace pages -- reading `Projects` in docs as a different concept from the current `Boards` label - -## See Also - -- [02_home_and_today.md](02_home_and_today.md) -- [04_inbox_and_review.md](04_inbox_and_review.md) -- [09_troubleshooting.md](09_troubleshooting.md) +# Manual Chapter 01: Start Here + +This chapter explains what Taskdeck is for and how to get to first value quickly. + +## What Taskdeck Is For + +Taskdeck helps you move from messy input to explicit, reviewable work. + +The core promise is simple: + +1. capture something fast +2. shape it into a proposal +3. review the change before it lands +4. continue the real work on a board + +Taskdeck is not trying to hide automation behind silent mutation. The review step is part of the product, not a temporary limitation. + +## The Fastest Useful Path + +1. open `Home` +2. create or open a useful board +3. put one note or task into `Inbox` +4. run `Start Triage` +5. open `Review` +6. approve and execute one proposal +7. continue from the board + +If you only do one thing on your first run, do that loop once. + +## Glossary + +`Home` +- the default landing page and reset surface + +`Today` +- the agenda view for review, triage, and due or blocked board work + +`Inbox` +- where rough notes, pasted text, follow-ups, and transcripts wait to be shaped + +`Review` +- the trust gate where proposals stop before they touch a board + +`Boards` +- the shipped label for workspaces where cards live + +`Projects` +- the product-facing term that may appear in docs or later UI language for the same board concept + +`Queue` +- an advanced manual automation input surface, not the normal first-run path + +`Agents` +- future-facing supervised assistant surfaces that are not part of the shipped shell yet + +## What Is Shipped Today + +Shipped and part of the normal path: + +- `Home` +- `Today` +- `Inbox` +- `Review` +- `Boards` + +Shipped but usually advanced: + +- `Chat` +- `Activity` +- `Notifications` +- `Ops` +- `Access` +- `Archive` + +Planned, not shipped: + +- `Agents` +- `Runs` +- `Knowledge` +- `Integrations` + +## If You Want A Richer First Run + +From `frontend/taskdeck-web`: + +```bash +npm run demo:seed +``` + +Use the seeded workspace when: + +- you want real examples immediately +- you are evaluating the product rather than starting empty +- you need a walkthrough or training baseline + +## Common Mistakes + +- starting from `Queue` instead of `Inbox` +- thinking `Review` is optional +- expecting `Agents` or `Integrations` to be current top-level workspace pages +- reading `Projects` in docs as a different concept from the current `Boards` label + +## See Also + +- [02_home_and_today.md](02_home_and_today.md) +- [04_inbox_and_review.md](04_inbox_and_review.md) +- [09_troubleshooting.md](09_troubleshooting.md) diff --git a/docs/manual/02_home_and_today.md b/docs/manual/02_home_and_today.md index a4bd26a45..865639f70 100644 --- a/docs/manual/02_home_and_today.md +++ b/docs/manual/02_home_and_today.md @@ -1,106 +1,106 @@ -# Manual Chapter 02: Home And Today - -This chapter covers the normal daily shell. - -## Home - -### When should I use this page? - -Use `Home` when: - -- you are not sure where to start -- you want the setup loop -- you want recent-board context and a recommended next action -- you need a reset back into the review-first path - -### What should I do here? - -Typical `Home` actions: - -1. reopen or continue the setup loop -2. jump into `Today` -3. capture directly to `Inbox` -4. open `Review` -5. reopen a recent board - -### If this page is empty - -`Home` is mostly a summary page, so "empty" usually means you have not created enough real work yet. - -Use this recovery path: - -1. start setup -2. create a useful board -3. add one Inbox item -4. review the resulting proposal - -### Common mistakes - -- treating `Home` like a dashboard you only visit once -- skipping the setup loop when the product still feels unclear -- trying to do detailed task work here instead of jumping to `Today`, `Review`, or a board - -## Today - -### When should I use this page? - -Use `Today` when: - -- you want one place to read the current workload -- you need to decide whether review, triage, overdue, due-today, or blocked work should come first -- you want the fastest morning reset without hunting through several pages - -### What should I do here? - -Read `Today` in this order: - -1. pending review -2. needs triage -3. overdue cards -4. due-today cards -5. blocked cards - -The intended habit is to decide proposals first, shape new input second, and only then go deep into board execution. - -### If this page is empty - -If `Today` shows little or nothing: - -- there may be no pending proposals right now -- Inbox may already be clear -- no board work may be overdue or due today - -That is not necessarily a problem. Open a board or capture new work in `Inbox`. - -### Common mistakes - -- treating board work as more urgent than pending review without checking first -- assuming an empty review or triage section means something is broken -- using `Today` as a substitute for detailed board planning - -## Daily Rhythm - -Morning: - -1. open `Today` -2. clear `Review` -3. triage fresh captures -4. choose the board work that matters now - -During work: - -1. capture follow-ups immediately -2. return to `Review` when proposals appear -3. keep the board as the place where approved work lands - -End of day: - -1. move cards honestly -2. capture loose ends to `Inbox` -3. leave the next day recoverable from `Home` and `Today` - -## See Also - -- [03_projects_and_cards.md](03_projects_and_cards.md) -- [04_inbox_and_review.md](04_inbox_and_review.md) -- [08_recipes.md](08_recipes.md) +# Manual Chapter 02: Home And Today + +This chapter covers the normal daily shell. + +## Home + +### When should I use this page? + +Use `Home` when: + +- you are not sure where to start +- you want the setup loop +- you want recent-board context and a recommended next action +- you need a reset back into the review-first path + +### What should I do here? + +Typical `Home` actions: + +1. reopen or continue the setup loop +2. jump into `Today` +3. capture directly to `Inbox` +4. open `Review` +5. reopen a recent board + +### If this page is empty + +`Home` is mostly a summary page, so "empty" usually means you have not created enough real work yet. + +Use this recovery path: + +1. start setup +2. create a useful board +3. add one Inbox item +4. review the resulting proposal + +### Common mistakes + +- treating `Home` like a dashboard you only visit once +- skipping the setup loop when the product still feels unclear +- trying to do detailed task work here instead of jumping to `Today`, `Review`, or a board + +## Today + +### When should I use this page? + +Use `Today` when: + +- you want one place to read the current workload +- you need to decide whether review, triage, overdue, due-today, or blocked work should come first +- you want the fastest morning reset without hunting through several pages + +### What should I do here? + +Read `Today` in this order: + +1. pending review +2. needs triage +3. overdue cards +4. due-today cards +5. blocked cards + +The intended habit is to decide proposals first, shape new input second, and only then go deep into board execution. + +### If this page is empty + +If `Today` shows little or nothing: + +- there may be no pending proposals right now +- Inbox may already be clear +- no board work may be overdue or due today + +That is not necessarily a problem. Open a board or capture new work in `Inbox`. + +### Common mistakes + +- treating board work as more urgent than pending review without checking first +- assuming an empty review or triage section means something is broken +- using `Today` as a substitute for detailed board planning + +## Daily Rhythm + +Morning: + +1. open `Today` +2. clear `Review` +3. triage fresh captures +4. choose the board work that matters now + +During work: + +1. capture follow-ups immediately +2. return to `Review` when proposals appear +3. keep the board as the place where approved work lands + +End of day: + +1. move cards honestly +2. capture loose ends to `Inbox` +3. leave the next day recoverable from `Home` and `Today` + +## See Also + +- [03_projects_and_cards.md](03_projects_and_cards.md) +- [04_inbox_and_review.md](04_inbox_and_review.md) +- [08_recipes.md](08_recipes.md) diff --git a/docs/manual/03_projects_and_cards.md b/docs/manual/03_projects_and_cards.md index 465fbaaf5..f188e6a49 100644 --- a/docs/manual/03_projects_and_cards.md +++ b/docs/manual/03_projects_and_cards.md @@ -1,97 +1,97 @@ -# Manual Chapter 03: Projects And Cards - -This chapter covers board-centered work. - -## Boards And Projects - -The shipped UI label is `Boards`. - -The docs may sometimes say `project` to describe the same workspace idea: - -- a place where approved work becomes visible -- the home for cards, comments, labels, due dates, and collaboration - -Today, use `Boards` in the product and read `project` in the docs as the same concept. - -## When should I use this page? - -Use a board when: - -- the work is already clear enough to live on cards -- you need to organize work by columns -- you want comments, due dates, blocked reasons, or labels -- you want board-specific actions such as `Capture here` or `Review proposals` - -## Board Basics - -Boards can contain: - -- columns -- cards -- labels -- due dates -- blocked states and blocked reasons -- threaded comments and mentions - -Boards can also be archived and restored. - -## Board Action Rail - -When you are inside a board, the action rail keeps the normal loop anchored to that board: - -- `Capture here` -- `Ask assistant` -- `Review proposals` -- `Add card` - -Use it when the new input already belongs to a specific board and you do not want to lose context by navigating away. - -## Starter Packs - -Starter packs help create a useful board faster by scaffolding: - -- columns -- labels -- optional seed cards - -Starter packs are safe to reapply because the apply behavior is designed to be idempotent and conflict-aware. - -## Comments And Collaboration - -Use comments when: - -- context belongs to a card -- you want history others can read later -- you need a mention to notify someone - -Use blocked state when: - -- work is waiting on a decision, dependency, or missing input - -## If this page feels empty - -If you have no boards yet: - -1. go back to `Home` or `Today` -2. use the setup loop or create a board directly -3. optionally apply a starter pack -4. run the Inbox -> Review -> Board loop once - -If the board exists but has little on it: - -- capture new input with `Capture here` -- review pending proposals for that board -- add a card directly if the task is already obvious - -## Common mistakes - -- trying to manage rough, unshaped notes directly as board work -- treating the board as the first stop for every idea instead of using `Inbox` -- ignoring board-specific capture or review actions and losing context by navigating away -- assuming starter packs are one-time only - -## See Also - -- [02_home_and_today.md](02_home_and_today.md) -- [04_inbox_and_review.md](04_inbox_and_review.md) -- [08_recipes.md](08_recipes.md) +# Manual Chapter 03: Projects And Cards + +This chapter covers board-centered work. + +## Boards And Projects + +The shipped UI label is `Boards`. + +The docs may sometimes say `project` to describe the same workspace idea: + +- a place where approved work becomes visible +- the home for cards, comments, labels, due dates, and collaboration + +Today, use `Boards` in the product and read `project` in the docs as the same concept. + +## When should I use this page? + +Use a board when: + +- the work is already clear enough to live on cards +- you need to organize work by columns +- you want comments, due dates, blocked reasons, or labels +- you want board-specific actions such as `Capture here` or `Review proposals` + +## Board Basics + +Boards can contain: + +- columns +- cards +- labels +- due dates +- blocked states and blocked reasons +- threaded comments and mentions + +Boards can also be archived and restored. + +## Board Action Rail + +When you are inside a board, the action rail keeps the normal loop anchored to that board: + +- `Capture here` +- `Ask assistant` +- `Review proposals` +- `Add card` + +Use it when the new input already belongs to a specific board and you do not want to lose context by navigating away. + +## Starter Packs + +Starter packs help create a useful board faster by scaffolding: + +- columns +- labels +- optional seed cards + +Starter packs are safe to reapply because the apply behavior is designed to be idempotent and conflict-aware. + +## Comments And Collaboration + +Use comments when: + +- context belongs to a card +- you want history others can read later +- you need a mention to notify someone + +Use blocked state when: + +- work is waiting on a decision, dependency, or missing input + +## If this page feels empty + +If you have no boards yet: + +1. go back to `Home` or `Today` +2. use the setup loop or create a board directly +3. optionally apply a starter pack +4. run the Inbox -> Review -> Board loop once + +If the board exists but has little on it: + +- capture new input with `Capture here` +- review pending proposals for that board +- add a card directly if the task is already obvious + +## Common mistakes + +- trying to manage rough, unshaped notes directly as board work +- treating the board as the first stop for every idea instead of using `Inbox` +- ignoring board-specific capture or review actions and losing context by navigating away +- assuming starter packs are one-time only + +## See Also + +- [02_home_and_today.md](02_home_and_today.md) +- [04_inbox_and_review.md](04_inbox_and_review.md) +- [08_recipes.md](08_recipes.md) diff --git a/docs/manual/04_inbox_and_review.md b/docs/manual/04_inbox_and_review.md index 6614c1cd9..64eb3b33a 100644 --- a/docs/manual/04_inbox_and_review.md +++ b/docs/manual/04_inbox_and_review.md @@ -1,113 +1,113 @@ -# Manual Chapter 04: Inbox And Review - -This chapter explains the capture and review loop. - -## Inbox - -### When should I use this page? - -Use `Inbox` when: - -- the input is rough -- you want to capture it before you forget it -- you are not ready to create or edit board work directly - -Typical examples: - -- a bug note -- pasted meeting output -- a transcript -- a follow-up idea -- a rough checklist or plan - -### What happens here? - -The normal sequence is: - -1. capture an item -2. open it in `Inbox` -3. run `Start Triage` -4. let Taskdeck prepare a proposal-ready change -5. open the linked proposal in `Review` - -### If this page is empty - -If `Inbox` is empty: - -- nothing is waiting to be shaped right now -- start from `Home` or `Today` and add a fresh note -- if you want examples immediately, seed the demo workspace - -### Common mistakes - -- using `Queue` instead of `Inbox` for normal messy intake -- assuming triage directly edits a board without passing through `Review` -- leaving important context in local notes instead of capturing it here - -## Review - -### When should I use this page? - -Use `Review` when: - -- you need the trust boundary before a board changes -- you want to inspect summary, impact, risk, provenance, and affected entities -- you need to approve, reject, or execute explicitly - -### What should I look at first? - -For each proposal, check: - -1. plain-language summary -2. planned changes -3. risk cue -4. source and provenance -5. affected entities -6. board deep links if you want more context - -### What do the main actions mean? - -`Approve` -- accept the proposal and make it executable - -`Reject` -- stop the proposal and optionally explain why - -`Execute` -- apply an already approved proposal to the board - -`View Diff` -- inspect the detailed change payload - -### If this page is empty - -If `Review` is empty: - -- there may be no proposals waiting for a decision -- `Inbox` may not have produced a proposal yet -- you may simply be clear right now - -The normal recovery path is to go back to `Inbox` and run triage on a new or failed capture. - -## Risk, Provenance, And Trust - -`Review` is where Taskdeck makes automation legible: - -- provenance shows where the request came from -- risk cues show how cautious the proposed change should feel -- review links and board links help you keep context - -If the product asks you for approval, that is working as intended. - -## Common mistakes - -- thinking `Approve` and `Execute` are the same step -- skipping provenance and affected entities when the change looks simple -- expecting `Review` to be only for power users -- assuming a board should change before a proposal exists - -## See Also - -- [02_home_and_today.md](02_home_and_today.md) -- [03_projects_and_cards.md](03_projects_and_cards.md) -- [09_troubleshooting.md](09_troubleshooting.md) +# Manual Chapter 04: Inbox And Review + +This chapter explains the capture and review loop. + +## Inbox + +### When should I use this page? + +Use `Inbox` when: + +- the input is rough +- you want to capture it before you forget it +- you are not ready to create or edit board work directly + +Typical examples: + +- a bug note +- pasted meeting output +- a transcript +- a follow-up idea +- a rough checklist or plan + +### What happens here? + +The normal sequence is: + +1. capture an item +2. open it in `Inbox` +3. run `Start Triage` +4. let Taskdeck prepare a proposal-ready change +5. open the linked proposal in `Review` + +### If this page is empty + +If `Inbox` is empty: + +- nothing is waiting to be shaped right now +- start from `Home` or `Today` and add a fresh note +- if you want examples immediately, seed the demo workspace + +### Common mistakes + +- using `Queue` instead of `Inbox` for normal messy intake +- assuming triage directly edits a board without passing through `Review` +- leaving important context in local notes instead of capturing it here + +## Review + +### When should I use this page? + +Use `Review` when: + +- you need the trust boundary before a board changes +- you want to inspect summary, impact, risk, provenance, and affected entities +- you need to approve, reject, or execute explicitly + +### What should I look at first? + +For each proposal, check: + +1. plain-language summary +2. planned changes +3. risk cue +4. source and provenance +5. affected entities +6. board deep links if you want more context + +### What do the main actions mean? + +`Approve` +- accept the proposal and make it executable + +`Reject` +- stop the proposal and optionally explain why + +`Execute` +- apply an already approved proposal to the board + +`View Diff` +- inspect the detailed change payload + +### If this page is empty + +If `Review` is empty: + +- there may be no proposals waiting for a decision +- `Inbox` may not have produced a proposal yet +- you may simply be clear right now + +The normal recovery path is to go back to `Inbox` and run triage on a new or failed capture. + +## Risk, Provenance, And Trust + +`Review` is where Taskdeck makes automation legible: + +- provenance shows where the request came from +- risk cues show how cautious the proposed change should feel +- review links and board links help you keep context + +If the product asks you for approval, that is working as intended. + +## Common mistakes + +- thinking `Approve` and `Execute` are the same step +- skipping provenance and affected entities when the change looks simple +- expecting `Review` to be only for power users +- assuming a board should change before a proposal exists + +## See Also + +- [02_home_and_today.md](02_home_and_today.md) +- [03_projects_and_cards.md](03_projects_and_cards.md) +- [09_troubleshooting.md](09_troubleshooting.md) diff --git a/docs/manual/05_advanced_automation.md b/docs/manual/05_advanced_automation.md index 9a21b40c5..d09efca2f 100644 --- a/docs/manual/05_advanced_automation.md +++ b/docs/manual/05_advanced_automation.md @@ -1,112 +1,112 @@ -# Manual Chapter 05: Advanced Automation And Diagnostics - -This chapter covers shipped surfaces that are useful, but not part of the normal first-run path. - -## Before You Open These Pages - -If you are a normal product user, stay mostly in: - -- `Home` -- `Today` -- `Inbox` -- `Review` -- `Boards` - -Open the pages below only when you have a reason. - -## Chat - -### When should I use this page? - -Use `Chat` when: - -- you want a conversational board-scoped workflow -- you need follow-up help beyond the normal review loop - -### Common mistakes - -- using `Chat` as the default first-run path instead of `Inbox` and `Review` - -## Queue - -### When should I use this page? - -Use `Queue` when: - -- you intentionally want the manual instruction surface -- you are doing a narrow advanced automation request -- you are debugging or operating the system - -### Common mistakes - -- using `Queue` for ordinary capture intake -- treating it as the recommended path for novices - -## Notifications - -### When should I use this page? - -Use `Notifications` when: - -- you want mention or proposal-outcome updates -- you need a personal event inbox - -## Activity - -### When should I use this page? - -Use `Activity` when: - -- you need audit-style history -- you want to inspect changes across boards, entities, or users - -### Common mistakes - -- expecting it to replace `Review` or the board workflow -- assuming selector-heavy activity filters are part of the basic path - -## Ops - -### When should I use this page? - -Use `Ops` when: - -- you are diagnosing a system-level problem -- you need logs, endpoint probing, or operator tooling - -### Common mistakes - -- opening it for normal day-to-day task management - -## Access - -### When should I use this page? - -Use `Access` when: - -- you need to manage board membership or roles -- you are checking permissions on a board - -## Archive - -### When should I use this page? - -Use `Archive` when: - -- you need to restore archived boards or items -- you want to hide or review old archived boards - -## If These Pages Are Missing - -Some advanced pages depend on feature flags or your current role. - -If you cannot find a page: - -- check `Settings` -- confirm the relevant feature is enabled -- confirm your role if the page is operator-scoped - -## See Also - -- [04_inbox_and_review.md](04_inbox_and_review.md) -- [09_troubleshooting.md](09_troubleshooting.md) +# Manual Chapter 05: Advanced Automation And Diagnostics + +This chapter covers shipped surfaces that are useful, but not part of the normal first-run path. + +## Before You Open These Pages + +If you are a normal product user, stay mostly in: + +- `Home` +- `Today` +- `Inbox` +- `Review` +- `Boards` + +Open the pages below only when you have a reason. + +## Chat + +### When should I use this page? + +Use `Chat` when: + +- you want a conversational board-scoped workflow +- you need follow-up help beyond the normal review loop + +### Common mistakes + +- using `Chat` as the default first-run path instead of `Inbox` and `Review` + +## Queue + +### When should I use this page? + +Use `Queue` when: + +- you intentionally want the manual instruction surface +- you are doing a narrow advanced automation request +- you are debugging or operating the system + +### Common mistakes + +- using `Queue` for ordinary capture intake +- treating it as the recommended path for novices + +## Notifications + +### When should I use this page? + +Use `Notifications` when: + +- you want mention or proposal-outcome updates +- you need a personal event inbox + +## Activity + +### When should I use this page? + +Use `Activity` when: + +- you need audit-style history +- you want to inspect changes across boards, entities, or users + +### Common mistakes + +- expecting it to replace `Review` or the board workflow +- assuming selector-heavy activity filters are part of the basic path + +## Ops + +### When should I use this page? + +Use `Ops` when: + +- you are diagnosing a system-level problem +- you need logs, endpoint probing, or operator tooling + +### Common mistakes + +- opening it for normal day-to-day task management + +## Access + +### When should I use this page? + +Use `Access` when: + +- you need to manage board membership or roles +- you are checking permissions on a board + +## Archive + +### When should I use this page? + +Use `Archive` when: + +- you need to restore archived boards or items +- you want to hide or review old archived boards + +## If These Pages Are Missing + +Some advanced pages depend on feature flags or your current role. + +If you cannot find a page: + +- check `Settings` +- confirm the relevant feature is enabled +- confirm your role if the page is operator-scoped + +## See Also + +- [04_inbox_and_review.md](04_inbox_and_review.md) +- [09_troubleshooting.md](09_troubleshooting.md) diff --git a/docs/manual/06_agents.md b/docs/manual/06_agents.md index 49b96ac2b..56116bf6a 100644 --- a/docs/manual/06_agents.md +++ b/docs/manual/06_agents.md @@ -1,20 +1,20 @@ -# Manual Chapter 06: Agents - -This chapter is reserved for future shipped `Agents` and `Runs` surfaces. - -Those pages are not part of the current shipped workspace shell. - -When agent surfaces land, this chapter should explain: - -- what an agent is -- what a run is -- how policy and review thresholds work -- how to inspect run traces and resulting proposals - -Until then, do not treat `Agents` as a missing setup problem. -It is roadmap work, not a hidden toggle in the normal shell. - -## See Also - -- [01_start_here.md](01_start_here.md) -- [07_integrations_and_knowledge.md](07_integrations_and_knowledge.md) +# Manual Chapter 06: Agents + +This chapter is reserved for future shipped `Agents` and `Runs` surfaces. + +Those pages are not part of the current shipped workspace shell. + +When agent surfaces land, this chapter should explain: + +- what an agent is +- what a run is +- how policy and review thresholds work +- how to inspect run traces and resulting proposals + +Until then, do not treat `Agents` as a missing setup problem. +It is roadmap work, not a hidden toggle in the normal shell. + +## See Also + +- [01_start_here.md](01_start_here.md) +- [07_integrations_and_knowledge.md](07_integrations_and_knowledge.md) diff --git a/docs/manual/07_integrations_and_knowledge.md b/docs/manual/07_integrations_and_knowledge.md index 3b6185b6d..908adbe3b 100644 --- a/docs/manual/07_integrations_and_knowledge.md +++ b/docs/manual/07_integrations_and_knowledge.md @@ -1,19 +1,19 @@ -# Manual Chapter 07: Integrations And Knowledge - -This chapter is reserved for future shipped `Integrations` and `Knowledge` surfaces. - -The current product already has technical foundations such as imports and webhooks, but it does not yet expose a normal-user workspace chapter of shipped pages called `Integrations` or `Knowledge`. - -When those pages land, this chapter should explain: - -- what integrations are for -- how imports and connectors fit the normal workflow -- what knowledge or search surfaces exist -- how future connector or knowledge actions stay review-safe - -Until then, read mentions of integrations or knowledge as roadmap direction, not current navigation. - -## See Also - -- [05_advanced_automation.md](05_advanced_automation.md) -- [06_agents.md](06_agents.md) +# Manual Chapter 07: Integrations And Knowledge + +This chapter is reserved for future shipped `Integrations` and `Knowledge` surfaces. + +The current product already has technical foundations such as imports and webhooks, but it does not yet expose a normal-user workspace chapter of shipped pages called `Integrations` or `Knowledge`. + +When those pages land, this chapter should explain: + +- what integrations are for +- how imports and connectors fit the normal workflow +- what knowledge or search surfaces exist +- how future connector or knowledge actions stay review-safe + +Until then, read mentions of integrations or knowledge as roadmap direction, not current navigation. + +## See Also + +- [05_advanced_automation.md](05_advanced_automation.md) +- [06_agents.md](06_agents.md) diff --git a/docs/manual/08_recipes.md b/docs/manual/08_recipes.md index 96f5f086d..08ec15cba 100644 --- a/docs/manual/08_recipes.md +++ b/docs/manual/08_recipes.md @@ -1,60 +1,60 @@ -# Manual Chapter 08: Recipes - -This chapter gives short, repeatable workflows. - -## Recipe: Run The Core Loop Once - -1. open `Home` -2. create or choose a useful board -3. add one rough note to `Inbox` -4. run `Start Triage` -5. open `Review` -6. approve and execute the proposal -7. open the board and confirm the result - -Use this recipe when the product still feels abstract. - -## Recipe: Morning Reset - -1. open `Today` -2. clear `Pending review` -3. clear `Needs triage` -4. inspect `Overdue cards` -5. pick the board work that matters now - -Use this recipe when you already have a real workspace and want the shortest daily reset. - -## Recipe: Capture Something Without Losing Board Context - -1. open the relevant board -2. use `Capture here` -3. return to `Review proposals` from the same board context -4. execute if the proposal is correct -5. keep working on the board - -Use this when the input already belongs to one board. - -## Recipe: Recover From An Empty Workspace - -1. open `Home` -2. start the setup loop -3. create a useful board or apply a starter pack -4. add one Inbox item -5. complete the review and execution loop - -If you want richer examples immediately, seed the demo workspace. - -## Recipe: Safe Advanced Follow-Up - -1. stay in `Review` for normal proposal decisions -2. open `Chat` only if you need conversational follow-up -3. open `Queue` only if you need the explicit manual instruction path -4. open `Ops` only for diagnostics - -Use this when you are not sure whether an advanced page is appropriate. - -## See Also - -- [02_home_and_today.md](02_home_and_today.md) -- [03_projects_and_cards.md](03_projects_and_cards.md) -- [04_inbox_and_review.md](04_inbox_and_review.md) +# Manual Chapter 08: Recipes + +This chapter gives short, repeatable workflows. + +## Recipe: Run The Core Loop Once + +1. open `Home` +2. create or choose a useful board +3. add one rough note to `Inbox` +4. run `Start Triage` +5. open `Review` +6. approve and execute the proposal +7. open the board and confirm the result + +Use this recipe when the product still feels abstract. + +## Recipe: Morning Reset + +1. open `Today` +2. clear `Pending review` +3. clear `Needs triage` +4. inspect `Overdue cards` +5. pick the board work that matters now + +Use this recipe when you already have a real workspace and want the shortest daily reset. + +## Recipe: Capture Something Without Losing Board Context + +1. open the relevant board +2. use `Capture here` +3. return to `Review proposals` from the same board context +4. execute if the proposal is correct +5. keep working on the board + +Use this when the input already belongs to one board. + +## Recipe: Recover From An Empty Workspace + +1. open `Home` +2. start the setup loop +3. create a useful board or apply a starter pack +4. add one Inbox item +5. complete the review and execution loop + +If you want richer examples immediately, seed the demo workspace. + +## Recipe: Safe Advanced Follow-Up + +1. stay in `Review` for normal proposal decisions +2. open `Chat` only if you need conversational follow-up +3. open `Queue` only if you need the explicit manual instruction path +4. open `Ops` only for diagnostics + +Use this when you are not sure whether an advanced page is appropriate. + +## See Also + +- [02_home_and_today.md](02_home_and_today.md) +- [03_projects_and_cards.md](03_projects_and_cards.md) +- [04_inbox_and_review.md](04_inbox_and_review.md) diff --git a/docs/manual/09_troubleshooting.md b/docs/manual/09_troubleshooting.md index 5e9bc53f6..74aa76c3b 100644 --- a/docs/manual/09_troubleshooting.md +++ b/docs/manual/09_troubleshooting.md @@ -1,105 +1,105 @@ -# Manual Chapter 09: Troubleshooting - -This chapter answers the most common first-run and daily-use questions. - -## Why is `Home` not telling me much? - -`Home` reflects the state of your workspace. If it feels thin: - -1. start or replay setup -2. create a useful board -3. add one Inbox item -4. run the loop once - -## Why is `Today` mostly empty? - -That usually means: - -- no proposals are waiting -- Inbox does not need triage -- no cards are overdue or due today - -Open a board or capture new work in `Inbox`. An empty `Today` is not automatically an error. - -## Why is `Inbox` empty? - -Nothing is waiting to be shaped right now. - -Recovery path: - -1. capture a note from `Home`, `Today`, or a board -2. reopen it in `Inbox` -3. run `Start Triage` - -## Why is `Review` empty? - -No proposals need a decision yet. - -If you expected one: - -1. check whether the related Inbox item actually started triage -2. refresh `Review` -3. reopen the capture item and inspect its current status - -## Why do I need review before apply? - -Because review-first behavior is the trust model: - -- proposals show what will change -- approval separates suggestion from action -- execution stays explicit - -If the product asks for review, it is protecting the board workflow rather than slowing you down by accident. - -## What does risk mean on a proposal? - -Risk is a cue for how cautious the proposed change should feel. - -Use it to decide: - -- whether you want more context before approval -- whether rejection should include a reason -- whether to inspect the diff before execution - -## Why do docs say project but the UI says board? - -The shipped label is still `Boards`. -Some docs use `project` as the broader product-facing idea for the same workspace. - -For now, read them as the same thing. - -## Where are the advanced pages? - -Some advanced pages: - -- are hidden behind feature flags -- depend on role or operator context -- are intentionally secondary to the normal path - -Check `Settings` if a page seems missing. - -## How do I get a sample workspace? - -From `frontend/taskdeck-web`: - -```bash -npm run demo:seed -``` - -Use this when you want realistic examples instead of starting from empty state. - -## Triage failed. What should I check? - -Check: - -1. whether the capture item is still in a triageable state -2. whether the current runtime or provider configuration is valid -3. whether the item already produced a proposal or failure state - -If you still need to debug the system itself, that is the point where `Ops` becomes relevant. - -## See Also - -- [01_start_here.md](01_start_here.md) -- [04_inbox_and_review.md](04_inbox_and_review.md) -- [05_advanced_automation.md](05_advanced_automation.md) +# Manual Chapter 09: Troubleshooting + +This chapter answers the most common first-run and daily-use questions. + +## Why is `Home` not telling me much? + +`Home` reflects the state of your workspace. If it feels thin: + +1. start or replay setup +2. create a useful board +3. add one Inbox item +4. run the loop once + +## Why is `Today` mostly empty? + +That usually means: + +- no proposals are waiting +- Inbox does not need triage +- no cards are overdue or due today + +Open a board or capture new work in `Inbox`. An empty `Today` is not automatically an error. + +## Why is `Inbox` empty? + +Nothing is waiting to be shaped right now. + +Recovery path: + +1. capture a note from `Home`, `Today`, or a board +2. reopen it in `Inbox` +3. run `Start Triage` + +## Why is `Review` empty? + +No proposals need a decision yet. + +If you expected one: + +1. check whether the related Inbox item actually started triage +2. refresh `Review` +3. reopen the capture item and inspect its current status + +## Why do I need review before apply? + +Because review-first behavior is the trust model: + +- proposals show what will change +- approval separates suggestion from action +- execution stays explicit + +If the product asks for review, it is protecting the board workflow rather than slowing you down by accident. + +## What does risk mean on a proposal? + +Risk is a cue for how cautious the proposed change should feel. + +Use it to decide: + +- whether you want more context before approval +- whether rejection should include a reason +- whether to inspect the diff before execution + +## Why do docs say project but the UI says board? + +The shipped label is still `Boards`. +Some docs use `project` as the broader product-facing idea for the same workspace. + +For now, read them as the same thing. + +## Where are the advanced pages? + +Some advanced pages: + +- are hidden behind feature flags +- depend on role or operator context +- are intentionally secondary to the normal path + +Check `Settings` if a page seems missing. + +## How do I get a sample workspace? + +From `frontend/taskdeck-web`: + +```bash +npm run demo:seed +``` + +Use this when you want realistic examples instead of starting from empty state. + +## Triage failed. What should I check? + +Check: + +1. whether the capture item is still in a triageable state +2. whether the current runtime or provider configuration is valid +3. whether the item already produced a proposal or failure state + +If you still need to debug the system itself, that is the point where `Ops` becomes relevant. + +## See Also + +- [01_start_here.md](01_start_here.md) +- [04_inbox_and_review.md](04_inbox_and_review.md) +- [05_advanced_automation.md](05_advanced_automation.md) diff --git a/docs/manual/README.md b/docs/manual/README.md index 97b2d84ad..fdb5683d9 100644 --- a/docs/manual/README.md +++ b/docs/manual/README.md @@ -1,75 +1,75 @@ -# Manual Structure Guide - -Purpose: - -- keep the user-manual architecture explicit without turning `docs/` root back into a large mixed bag -- define how `START_HERE.md`, `USER_MANUAL.md`, manual chapters, and in-app help should relate to each other - -Current living entrypoints: - -- `docs/START_HERE.md` - - first-run bridge doc for evaluators and new users -- `docs/USER_MANUAL.md` - - manual index for the shipped shell and chapter map - -Manual chapters: - -- `docs/manual/01_start_here.md` -- `docs/manual/02_home_and_today.md` -- `docs/manual/03_projects_and_cards.md` -- `docs/manual/04_inbox_and_review.md` -- `docs/manual/05_advanced_automation.md` -- `docs/manual/06_agents.md` -- `docs/manual/07_integrations_and_knowledge.md` -- `docs/manual/08_recipes.md` -- `docs/manual/09_troubleshooting.md` - -Shipped behavior still belongs in `docs/STATUS.md`, `docs/START_HERE.md`, `docs/USER_MANUAL.md`, and the relevant chapter files. - -## Chapter Shape - -1. `01_start_here.md` - - what Taskdeck is for - - 2-minute first-value path - - glossary for `Boards`, `Projects`, `Inbox`, `Review`, `Today`, and `Agents` -2. `02_home_and_today.md` - - `Home` - - `Today` - - daily and weekly routines -3. `03_projects_and_cards.md` - - board or project basics - - cards, labels, comments, due dates, blocked state - - starter packs and the board action rail -4. `04_inbox_and_review.md` - - capture sources - - triage - - proposal review - - risk, provenance, and trust model -5. `05_advanced_automation.md` - - `Chat` - - `Queue` - - `Notifications` - - `Activity` - - `Ops` - - `Archive` - - `Access` -6. `06_agents.md` - - future shipped `Agents` and `Runs` surfaces -7. `07_integrations_and_knowledge.md` - - future shipped imports, connectors, knowledge, and search surfaces -8. `08_recipes.md` - - concrete step-by-step workflows -9. `09_troubleshooting.md` - - empty pages - - triage failure - - review-before-apply reasoning - - advanced-page discoverability - - demo or sample workspace enablement - -## In-App Help Mapping - -Keep in-app help blocks mapped back to the manual structure: - +# Manual Structure Guide + +Purpose: + +- keep the user-manual architecture explicit without turning `docs/` root back into a large mixed bag +- define how `START_HERE.md`, `USER_MANUAL.md`, manual chapters, and in-app help should relate to each other + +Current living entrypoints: + +- `docs/START_HERE.md` + - first-run bridge doc for evaluators and new users +- `docs/USER_MANUAL.md` + - manual index for the shipped shell and chapter map + +Manual chapters: + +- `docs/manual/01_start_here.md` +- `docs/manual/02_home_and_today.md` +- `docs/manual/03_projects_and_cards.md` +- `docs/manual/04_inbox_and_review.md` +- `docs/manual/05_advanced_automation.md` +- `docs/manual/06_agents.md` +- `docs/manual/07_integrations_and_knowledge.md` +- `docs/manual/08_recipes.md` +- `docs/manual/09_troubleshooting.md` + +Shipped behavior still belongs in `docs/STATUS.md`, `docs/START_HERE.md`, `docs/USER_MANUAL.md`, and the relevant chapter files. + +## Chapter Shape + +1. `01_start_here.md` + - what Taskdeck is for + - 2-minute first-value path + - glossary for `Boards`, `Projects`, `Inbox`, `Review`, `Today`, and `Agents` +2. `02_home_and_today.md` + - `Home` + - `Today` + - daily and weekly routines +3. `03_projects_and_cards.md` + - board or project basics + - cards, labels, comments, due dates, blocked state + - starter packs and the board action rail +4. `04_inbox_and_review.md` + - capture sources + - triage + - proposal review + - risk, provenance, and trust model +5. `05_advanced_automation.md` + - `Chat` + - `Queue` + - `Notifications` + - `Activity` + - `Ops` + - `Archive` + - `Access` +6. `06_agents.md` + - future shipped `Agents` and `Runs` surfaces +7. `07_integrations_and_knowledge.md` + - future shipped imports, connectors, knowledge, and search surfaces +8. `08_recipes.md` + - concrete step-by-step workflows +9. `09_troubleshooting.md` + - empty pages + - triage failure + - review-before-apply reasoning + - advanced-page discoverability + - demo or sample workspace enablement + +## In-App Help Mapping + +Keep in-app help blocks mapped back to the manual structure: + | Help topic ID or future surface | Manual target | |---|---| | `home` | `02_home_and_today.md` | @@ -81,36 +81,36 @@ Keep in-app help blocks mapped back to the manual structure: | `board-access-selectors` | `05_advanced_automation.md` | | future `agents` or `runs` | `06_agents.md` | | future `integrations` or `knowledge` | `07_integrations_and_knowledge.md` | - -## Writing Rules - -- explain the user goal before the mechanism -- prefer examples over abstractions -- add `When should I use this page?` near the top of each section -- add `If this page is empty` when the route has a meaningful empty or first-run state -- add `Common mistakes` near the end of each section -- keep advanced or operator sections clearly separated from normal-user paths -- distinguish shipped behavior from future-facing placeholders explicitly - -## Ownership And Update Cadence - -Update the matching chapter whenever any of these change on a product surface: - -- route label or visible navigation placement -- help-callout purpose text -- empty-state guidance or recovery path -- first-run or setup-loop steps -- trust, review, or provenance wording that affects how users are meant to operate the page - -When a chapter changes materially, also check: - -1. `docs/START_HERE.md` -2. `docs/USER_MANUAL.md` -3. `docs/INDEX.md` - -## Split Rule - -The manual is now intentionally split because the novice-first shell is shipped and stable enough to justify chapter-level guidance. - -Keep `docs/USER_MANUAL.md` as the short manual index. -Keep the detailed guidance in the chapter files under `docs/manual/`. + +## Writing Rules + +- explain the user goal before the mechanism +- prefer examples over abstractions +- add `When should I use this page?` near the top of each section +- add `If this page is empty` when the route has a meaningful empty or first-run state +- add `Common mistakes` near the end of each section +- keep advanced or operator sections clearly separated from normal-user paths +- distinguish shipped behavior from future-facing placeholders explicitly + +## Ownership And Update Cadence + +Update the matching chapter whenever any of these change on a product surface: + +- route label or visible navigation placement +- help-callout purpose text +- empty-state guidance or recovery path +- first-run or setup-loop steps +- trust, review, or provenance wording that affects how users are meant to operate the page + +When a chapter changes materially, also check: + +1. `docs/START_HERE.md` +2. `docs/USER_MANUAL.md` +3. `docs/INDEX.md` + +## Split Rule + +The manual is now intentionally split because the novice-first shell is shipped and stable enough to justify chapter-level guidance. + +Keep `docs/USER_MANUAL.md` as the short manual index. +Keep the detailed guidance in the chapter files under `docs/manual/`. diff --git a/docs/platform/LLM_PROVIDER_SETUP_GUIDE.md b/docs/platform/LLM_PROVIDER_SETUP_GUIDE.md index 5e742740d..db61ac6f8 100644 --- a/docs/platform/LLM_PROVIDER_SETUP_GUIDE.md +++ b/docs/platform/LLM_PROVIDER_SETUP_GUIDE.md @@ -1,209 +1,209 @@ -# LLM Provider Runtime and Demo Setup Guide - -Last Updated: 2026-04-22 -Scope: Provider runtime setup for chat/capture automation and safe local demo operation. - -## Purpose - -Taskdeck keeps application services provider-agnostic through `ILlmProvider`, while retaining a safe default posture. -This guide defines what is now shipped and how to run OpenAI/Gemini demos without code changes. - -## Current Shipped State - -Backend provider runtime now supports: - -- `Mock` provider (default) -- `OpenAI` provider (config-gated) -- `Gemini` provider (config-gated) -- managed-key attribution baseline for provider-bound chat/capture requests (`#236`): - - server-derived actor/scope attribution is attached to `ChatCompletionRequest` - - provider adapters receive standardized attribution headers (`x-taskdeck-*`) - - OpenAI adapter maps pseudonymous end-user token to provider `user` field - - capture queue payload provenance now persists actor/correlation/source attribution metadata for audit follow-through - -Selection is deterministic through `LlmProviderSelectionPolicy`: - -- to use live providers (`OpenAI`/`Gemini`), live providers must be enabled (`EnableLiveProviders=true`) -- to use live providers in development-like environments, explicit live mode is required (`AllowLiveProvidersInDevelopment=true`) -- provider mode may be explicitly set to `Mock`, `OpenAI`, or `Gemini`; this guide's config example intentionally uses `Mock` as the safe default -- unknown provider values also fall back deterministically to `Mock` -- selected provider config must pass validation (`ApiKey`, `BaseUrl`, `Model`, `TimeoutSeconds`) -- `BaseUrl` is additionally validated by `SsrfProtectionService.ValidateLlmProviderUrl` (SEC-26 PR `#905`): private IPv4 (`127/8`, `10/8`, `172.16/12`, `192.168/16`), IPv6 ranges (`::1`, `fc00::/7`, `fe80::/10`), IPv4-mapped IPv6, cloud metadata hostnames (`metadata.google.internal`, `metadata.goog`, AWS IMDS `169.254.169.254`, AWS IMDSv2 IPv6 `fd00:ec2::254`, Alibaba `100.100.100.200`), and non-HTTPS URLs are rejected; the selection policy falls back to Mock when validation fails -- `HttpClient`s for OpenAI and Gemini use `OutboundWebhookConnectCallback` for DNS-level SSRF protection (defense against DNS rebinding where a hostname resolves to a private IP at connect time) and set `AllowAutoRedirect = false` to prevent redirect-based bypass - -If any live-provider condition fails, runtime degrades safely to `Mock`. - -### Development-mode localhost bypass (Ollama / LM Studio) - -To support local LLM workflows (Ollama, LM Studio, LocalAI, etc.), the SSRF -validator allows `localhost`/`127.0.0.1` `BaseUrl` values **only** when -both of the following are true: - -- the environment is `Development` (or a development-like host name) -- `Llm:AllowLiveProvidersInDevelopment = true` is set explicitly - -Under this bypass, HTTPS is also not required for `localhost` endpoints. -This exception is intentionally narrow: Staging/Production deployments can -never reach `localhost` LLM endpoints even if the configuration is cloned. -All other private IP ranges and cloud metadata hostnames stay blocked even -in Development. - -## Config Shape - -```json -{ - "Llm": { - "EnableLiveProviders": false, - "AllowLiveProvidersInDevelopment": false, - "Provider": "Mock", - "OpenAi": { - "ApiKey": "", - "BaseUrl": "https://api.openai.com/v1", - "Model": "gpt-4o-mini", - "TimeoutSeconds": 30 - }, - "Gemini": { - "ApiKey": "", - "BaseUrl": "https://generativelanguage.googleapis.com/v1beta", - "Model": "gemini-2.5-flash", - "TimeoutSeconds": 30 - } - } -} -``` - -## Demo Setup (OpenAI) - -Set: - -- `Llm__EnableLiveProviders=true` -- `Llm__AllowLiveProvidersInDevelopment=true` -- `Llm__Provider=OpenAI` -- `Llm__OpenAi__ApiKey=` - -Optional: - -- `Llm__OpenAi__Model=` -- `Llm__OpenAi__BaseUrl=https://api.openai.com/v1` -- `Llm__OpenAi__TimeoutSeconds=30` - -## Demo Setup (Gemini) - -Set: - -- `Llm__EnableLiveProviders=true` -- `Llm__AllowLiveProvidersInDevelopment=true` -- `Llm__Provider=Gemini` -- `Llm__Gemini__ApiKey=` - -Optional: - -- `Llm__Gemini__Model=` -- `Llm__Gemini__BaseUrl=https://generativelanguage.googleapis.com/v1beta` -- `Llm__Gemini__TimeoutSeconds=30` - -## Playwright Demo Auto-Enable - -For full Playwright-backed demos (`npm run demo:director` or `TASKDECK_RUN_DEMO=1 npx playwright test tests/e2e/stakeholder-demo.spec.ts --headed`): - -- if LLM steps are enabled and a usable live-provider key is present, the demo web server now auto-enables live providers for that run -- Gemini is preferred when any of these are present: - - `GEMINI_API_KEY` - - `TASKDECK_DEMO_GEMINI_API_KEY` - - `Llm__Gemini__ApiKey` -- use `TASKDECK_DEMO_LLM_PROVIDER=OpenAI` to force OpenAI instead of Gemini for a specific demo run -- use `TASKDECK_DEMO_LLM_PROVIDER=Gemini` to force Gemini even when the base environment is pinned to `Llm__Provider=Mock` -- use `TASKDECK_DEMO_LLM_PROVIDER=Mock` to keep the demo on mock explicitly even when live keys are present -- use `TASKDECK_DEMO_DISABLE_LIVE_LLM=1` to force demo runs back to mock even when keys are present -- use `TASKDECK_DEMO_SKIP_LLM=1` when the scenario/recorder should skip LLM-required steps and keep the backend on mock -- if the configured base provider has no usable key, demo auto-enable falls back to another available live-provider key instead of silently staying on mock -- deterministic smoke runs still remain mock-backed because `demo:director:smoke` sets `--skip-llm` -- when the demo runtime injects live-provider overrides, Playwright also disables existing-server reuse by default so a stale mock backend is not silently reused; set `TASKDECK_E2E_REUSE_EXISTING_SERVER=1` only if you intentionally want reuse anyway - -Opt-in live-provider verification outside demo mode: - -- set `TASKDECK_RUN_LIVE_LLM_TESTS=1` to let Playwright inject live-provider settings for the dedicated live chat probe without enabling the broader demo recorder -- use `npx playwright test tests/e2e/live-llm.spec.ts --headed --reporter=line` for a manual visible check -- use `npm run test:e2e:live-llm:headed` for the same local headed path through the package scripts - -## Product-Level Provider Truth - -Automation Chat now exposes provider-health state explicitly through: -- `GET /api/llm/chat/health` — returns config-validated health status -- `GET /api/llm/chat/health?probe=true` — sends a minimal completion to the configured provider and returns `isProbed: true` with reachability status -- the in-app provider-status banner in `Automation Chat` -- the `Verify LLM` button which calls the probe endpoint - -Note: -- `GET /api/llm/chat/health` is protected by the standard app auth on `ChatController` -- callers must use an authenticated session or a valid Bearer token -- unauthenticated requests return `401 Unauthorized`, so a direct browser or `curl` call without auth can fail even when the provider is healthy -- `?probe=true` makes a real API call to the upstream provider; use it intentionally since it consumes tokens - -Current operator-visible states: -- `verified` — probe confirmed live reachability (only after `?probe=true`) -- `configured` — config validation passed but reachability not proven -- `mock` — Mock provider active (deterministic, no live LLM) -- `unavailable` — provider configuration invalid or errored -- `error` — health check itself failed -- `loading` / `unknown` — transient states during resolution - -Degraded responses: -- when a live provider is configured but the API call fails (network, auth, parse), the response carries `messageType: "degraded"` with a `degradedReason` field -- the UI renders these with a visible warning border and reason text -- `degradedReason` is the primary structured field for failure detail; some fallback content may still include the reason in parenthetical text for compatibility - -This is intentionally separate from the broader demo tooling so an operator can tell whether a live LLM is actually hooked before trusting a manual chat pass. - -## Behavior Guarantees - -- application services remain provider-agnostic (`ChatService`, capture triage paths depend on `ILlmProvider` only) -- invalid/missing live-provider configuration does not crash requests -- provider adapters return deterministic fallback responses when upstream calls fail -- capture triage provenance persists `promptVersion`, `provider`, and `model` -- managed-key attribution metadata is server-derived and spoof-resistant: - - `/api/capture/items` ignores unknown actor fields because create payloads are strongly model-bound - - `/api/llm-queue` raw capture payload parsing rejects actor identity fields (`userId`/`ownerUserId`/`requestedByUserId`/`actor*`) and client-supplied provenance attribution fields - - provider mapping uses pseudonymous user tokens (no raw API secrets or personal identifiers) - -## Test Coverage Expectations (Implemented) - -- selection-policy unit coverage for `Mock`/`OpenAI`/`Gemini` and invalid-config fallback -- provider adapter unit coverage: - - OpenAI: success/failure + metadata checks - - Gemini: success/failure/invalid-response/invalid-config/cancellation + health + attribution header mapping -- API integration coverage: - - capture triage provenance includes provider/model - - chat flow validated using a non-mock provider stub with attribution assertions - - chat health endpoint returns explicit provider-status metadata for mock/live paths - - capture create ignores client-supplied actor identity payload fields - - queue ingest rejects spoofed provenance attribution fields -- frontend/operator coverage: - - Automation Chat shows explicit mock/live/degraded provider state - - opt-in Playwright live-provider probe validates a real first-turn response path when `TASKDECK_RUN_LIVE_LLM_TESTS=1` - -## Security and Trust Constraints - -- no direct board mutations from raw model output; proposal review remains mandatory -- do not log API keys or other secrets -- preserve auth/error contract behavior (`401/403/404`, `ApiErrorResponse`) -- keep live-provider usage explicitly opt-in - -## Managed-Key Abuse-Control Follow-Through - -Provider runtime support does not replace managed-key control-plane requirements. - -**User-facing policy**: When operating in managed-key mode, fair-use boundaries, privacy disclosures, and enforcement consequences are defined in `docs/security/MANAGED_KEY_USAGE_POLICY.md`. Operators and demo presenters should be familiar with this policy before enabling live providers for shared access. - -Continue tracked work in: - -- delivered: `#236` (identity attribution contract), `#239` (incident runbook at `docs/security/MANAGED_KEY_INCIDENT_RUNBOOK.md`), `#240` (fair-use policy at `docs/security/MANAGED_KEY_USAGE_POLICY.md`) -- partially delivered: `#238` (operator tooling + domain groundwork shipped; live-traffic automated containment is follow-up) -- remaining: `#235` (rate-limit and quota enforcement), `#237` (real-time abuse detection pipeline) - -## References - -- OpenAI API reference: https://platform.openai.com/docs/api-reference/introduction -- OpenAI responses migration guidance: https://developers.openai.com/api/docs/guides/migrate-to-responses/ -- Gemini API key docs: https://ai.google.dev/gemini-api/docs/api-key -- Gemini generateContent docs: https://ai.google.dev/gemini-api/docs/text-generation +# LLM Provider Runtime and Demo Setup Guide + +Last Updated: 2026-04-22 +Scope: Provider runtime setup for chat/capture automation and safe local demo operation. + +## Purpose + +Taskdeck keeps application services provider-agnostic through `ILlmProvider`, while retaining a safe default posture. +This guide defines what is now shipped and how to run OpenAI/Gemini demos without code changes. + +## Current Shipped State + +Backend provider runtime now supports: + +- `Mock` provider (default) +- `OpenAI` provider (config-gated) +- `Gemini` provider (config-gated) +- managed-key attribution baseline for provider-bound chat/capture requests (`#236`): + - server-derived actor/scope attribution is attached to `ChatCompletionRequest` + - provider adapters receive standardized attribution headers (`x-taskdeck-*`) + - OpenAI adapter maps pseudonymous end-user token to provider `user` field + - capture queue payload provenance now persists actor/correlation/source attribution metadata for audit follow-through + +Selection is deterministic through `LlmProviderSelectionPolicy`: + +- to use live providers (`OpenAI`/`Gemini`), live providers must be enabled (`EnableLiveProviders=true`) +- to use live providers in development-like environments, explicit live mode is required (`AllowLiveProvidersInDevelopment=true`) +- provider mode may be explicitly set to `Mock`, `OpenAI`, or `Gemini`; this guide's config example intentionally uses `Mock` as the safe default +- unknown provider values also fall back deterministically to `Mock` +- selected provider config must pass validation (`ApiKey`, `BaseUrl`, `Model`, `TimeoutSeconds`) +- `BaseUrl` is additionally validated by `SsrfProtectionService.ValidateLlmProviderUrl` (SEC-26 PR `#905`): private IPv4 (`127/8`, `10/8`, `172.16/12`, `192.168/16`), IPv6 ranges (`::1`, `fc00::/7`, `fe80::/10`), IPv4-mapped IPv6, cloud metadata hostnames (`metadata.google.internal`, `metadata.goog`, AWS IMDS `169.254.169.254`, AWS IMDSv2 IPv6 `fd00:ec2::254`, Alibaba `100.100.100.200`), and non-HTTPS URLs are rejected; the selection policy falls back to Mock when validation fails +- `HttpClient`s for OpenAI and Gemini use `OutboundWebhookConnectCallback` for DNS-level SSRF protection (defense against DNS rebinding where a hostname resolves to a private IP at connect time) and set `AllowAutoRedirect = false` to prevent redirect-based bypass + +If any live-provider condition fails, runtime degrades safely to `Mock`. + +### Development-mode localhost bypass (Ollama / LM Studio) + +To support local LLM workflows (Ollama, LM Studio, LocalAI, etc.), the SSRF +validator allows `localhost`/`127.0.0.1` `BaseUrl` values **only** when +both of the following are true: + +- the environment is `Development` (or a development-like host name) +- `Llm:AllowLiveProvidersInDevelopment = true` is set explicitly + +Under this bypass, HTTPS is also not required for `localhost` endpoints. +This exception is intentionally narrow: Staging/Production deployments can +never reach `localhost` LLM endpoints even if the configuration is cloned. +All other private IP ranges and cloud metadata hostnames stay blocked even +in Development. + +## Config Shape + +```json +{ + "Llm": { + "EnableLiveProviders": false, + "AllowLiveProvidersInDevelopment": false, + "Provider": "Mock", + "OpenAi": { + "ApiKey": "", + "BaseUrl": "https://api.openai.com/v1", + "Model": "gpt-4o-mini", + "TimeoutSeconds": 30 + }, + "Gemini": { + "ApiKey": "", + "BaseUrl": "https://generativelanguage.googleapis.com/v1beta", + "Model": "gemini-2.5-flash", + "TimeoutSeconds": 30 + } + } +} +``` + +## Demo Setup (OpenAI) + +Set: + +- `Llm__EnableLiveProviders=true` +- `Llm__AllowLiveProvidersInDevelopment=true` +- `Llm__Provider=OpenAI` +- `Llm__OpenAi__ApiKey=` + +Optional: + +- `Llm__OpenAi__Model=` +- `Llm__OpenAi__BaseUrl=https://api.openai.com/v1` +- `Llm__OpenAi__TimeoutSeconds=30` + +## Demo Setup (Gemini) + +Set: + +- `Llm__EnableLiveProviders=true` +- `Llm__AllowLiveProvidersInDevelopment=true` +- `Llm__Provider=Gemini` +- `Llm__Gemini__ApiKey=` + +Optional: + +- `Llm__Gemini__Model=` +- `Llm__Gemini__BaseUrl=https://generativelanguage.googleapis.com/v1beta` +- `Llm__Gemini__TimeoutSeconds=30` + +## Playwright Demo Auto-Enable + +For full Playwright-backed demos (`npm run demo:director` or `TASKDECK_RUN_DEMO=1 npx playwright test tests/e2e/stakeholder-demo.spec.ts --headed`): + +- if LLM steps are enabled and a usable live-provider key is present, the demo web server now auto-enables live providers for that run +- Gemini is preferred when any of these are present: + - `GEMINI_API_KEY` + - `TASKDECK_DEMO_GEMINI_API_KEY` + - `Llm__Gemini__ApiKey` +- use `TASKDECK_DEMO_LLM_PROVIDER=OpenAI` to force OpenAI instead of Gemini for a specific demo run +- use `TASKDECK_DEMO_LLM_PROVIDER=Gemini` to force Gemini even when the base environment is pinned to `Llm__Provider=Mock` +- use `TASKDECK_DEMO_LLM_PROVIDER=Mock` to keep the demo on mock explicitly even when live keys are present +- use `TASKDECK_DEMO_DISABLE_LIVE_LLM=1` to force demo runs back to mock even when keys are present +- use `TASKDECK_DEMO_SKIP_LLM=1` when the scenario/recorder should skip LLM-required steps and keep the backend on mock +- if the configured base provider has no usable key, demo auto-enable falls back to another available live-provider key instead of silently staying on mock +- deterministic smoke runs still remain mock-backed because `demo:director:smoke` sets `--skip-llm` +- when the demo runtime injects live-provider overrides, Playwright also disables existing-server reuse by default so a stale mock backend is not silently reused; set `TASKDECK_E2E_REUSE_EXISTING_SERVER=1` only if you intentionally want reuse anyway + +Opt-in live-provider verification outside demo mode: + +- set `TASKDECK_RUN_LIVE_LLM_TESTS=1` to let Playwright inject live-provider settings for the dedicated live chat probe without enabling the broader demo recorder +- use `npx playwright test tests/e2e/live-llm.spec.ts --headed --reporter=line` for a manual visible check +- use `npm run test:e2e:live-llm:headed` for the same local headed path through the package scripts + +## Product-Level Provider Truth + +Automation Chat now exposes provider-health state explicitly through: +- `GET /api/llm/chat/health` — returns config-validated health status +- `GET /api/llm/chat/health?probe=true` — sends a minimal completion to the configured provider and returns `isProbed: true` with reachability status +- the in-app provider-status banner in `Automation Chat` +- the `Verify LLM` button which calls the probe endpoint + +Note: +- `GET /api/llm/chat/health` is protected by the standard app auth on `ChatController` +- callers must use an authenticated session or a valid Bearer token +- unauthenticated requests return `401 Unauthorized`, so a direct browser or `curl` call without auth can fail even when the provider is healthy +- `?probe=true` makes a real API call to the upstream provider; use it intentionally since it consumes tokens + +Current operator-visible states: +- `verified` — probe confirmed live reachability (only after `?probe=true`) +- `configured` — config validation passed but reachability not proven +- `mock` — Mock provider active (deterministic, no live LLM) +- `unavailable` — provider configuration invalid or errored +- `error` — health check itself failed +- `loading` / `unknown` — transient states during resolution + +Degraded responses: +- when a live provider is configured but the API call fails (network, auth, parse), the response carries `messageType: "degraded"` with a `degradedReason` field +- the UI renders these with a visible warning border and reason text +- `degradedReason` is the primary structured field for failure detail; some fallback content may still include the reason in parenthetical text for compatibility + +This is intentionally separate from the broader demo tooling so an operator can tell whether a live LLM is actually hooked before trusting a manual chat pass. + +## Behavior Guarantees + +- application services remain provider-agnostic (`ChatService`, capture triage paths depend on `ILlmProvider` only) +- invalid/missing live-provider configuration does not crash requests +- provider adapters return deterministic fallback responses when upstream calls fail +- capture triage provenance persists `promptVersion`, `provider`, and `model` +- managed-key attribution metadata is server-derived and spoof-resistant: + - `/api/capture/items` ignores unknown actor fields because create payloads are strongly model-bound + - `/api/llm-queue` raw capture payload parsing rejects actor identity fields (`userId`/`ownerUserId`/`requestedByUserId`/`actor*`) and client-supplied provenance attribution fields + - provider mapping uses pseudonymous user tokens (no raw API secrets or personal identifiers) + +## Test Coverage Expectations (Implemented) + +- selection-policy unit coverage for `Mock`/`OpenAI`/`Gemini` and invalid-config fallback +- provider adapter unit coverage: + - OpenAI: success/failure + metadata checks + - Gemini: success/failure/invalid-response/invalid-config/cancellation + health + attribution header mapping +- API integration coverage: + - capture triage provenance includes provider/model + - chat flow validated using a non-mock provider stub with attribution assertions + - chat health endpoint returns explicit provider-status metadata for mock/live paths + - capture create ignores client-supplied actor identity payload fields + - queue ingest rejects spoofed provenance attribution fields +- frontend/operator coverage: + - Automation Chat shows explicit mock/live/degraded provider state + - opt-in Playwright live-provider probe validates a real first-turn response path when `TASKDECK_RUN_LIVE_LLM_TESTS=1` + +## Security and Trust Constraints + +- no direct board mutations from raw model output; proposal review remains mandatory +- do not log API keys or other secrets +- preserve auth/error contract behavior (`401/403/404`, `ApiErrorResponse`) +- keep live-provider usage explicitly opt-in + +## Managed-Key Abuse-Control Follow-Through + +Provider runtime support does not replace managed-key control-plane requirements. + +**User-facing policy**: When operating in managed-key mode, fair-use boundaries, privacy disclosures, and enforcement consequences are defined in `docs/security/MANAGED_KEY_USAGE_POLICY.md`. Operators and demo presenters should be familiar with this policy before enabling live providers for shared access. + +Continue tracked work in: + +- delivered: `#236` (identity attribution contract), `#239` (incident runbook at `docs/security/MANAGED_KEY_INCIDENT_RUNBOOK.md`), `#240` (fair-use policy at `docs/security/MANAGED_KEY_USAGE_POLICY.md`) +- partially delivered: `#238` (operator tooling + domain groundwork shipped; live-traffic automated containment is follow-up) +- remaining: `#235` (rate-limit and quota enforcement), `#237` (real-time abuse detection pipeline) + +## References + +- OpenAI API reference: https://platform.openai.com/docs/api-reference/introduction +- OpenAI responses migration guidance: https://developers.openai.com/api/docs/guides/migrate-to-responses/ +- Gemini API key docs: https://ai.google.dev/gemini-api/docs/api-key +- Gemini generateContent docs: https://ai.google.dev/gemini-api/docs/text-generation diff --git a/docs/product/DEMO_PLAYBOOK.md b/docs/product/DEMO_PLAYBOOK.md index 468f41238..b8b0e8fbb 100644 --- a/docs/product/DEMO_PLAYBOOK.md +++ b/docs/product/DEMO_PLAYBOOK.md @@ -1,326 +1,326 @@ -# Taskdeck Demo Playbook - -Taskdeck has a lot of capability under the hood. If you click around a fresh instance, some pages look empty because they are event-driven and only populate after specific flows. - -This playbook gives you: - -1. A one-command seed so the UI starts populated. -2. Scenario harness commands for repeatable demos. -3. A short stakeholder flow and an opt-in recorder. - -Use [START_HERE.md](../START_HERE.md) first if you are trying to understand the product. -This playbook is for seeded demos, stakeholder walkthroughs, and regression/operator use, not the main onboarding path. - -Core story: - -Capture -> Triage -> Proposal -> Apply -> Board - -Saul-facing recording contract: -- `docs/product/SAUL_DEMO_REHEARSAL_CONTRACT.md` - -## Quick Start - -1. Start backend - -```bash -cd backend/src/Taskdeck.Api -dotnet run -``` - -2. Start frontend - -```bash -cd frontend/taskdeck-web -npm install -npm run dev -``` - -Default URLs: - -- API: `http://localhost:5000/api` -- UI: `http://localhost:5173` -- Local fallback ports for UI: `http://localhost:4173`, `http://localhost:5001` -- Health-check endpoints (note: these are **not** under the `/api` prefix): - - `http://localhost:5000/health/live` — liveness probe - - `http://localhost:5000/health/ready` — readiness probe - -3. Seed baseline demo data - -```bash -cd frontend/taskdeck-web -npm run demo:seed -``` - -The seeder creates demo users, boards, Inbox items, proposals, queue activity, notifications, and Ops logs. -On reruns against the canonical demo account, it now reuses the seeded artifacts it can identify instead of appending a fresh copy of every capture, queue sample, comment, chat session, and Ops evidence item. - -Use `npm run demo:seed -- --reset` to delete all demo boards before seeding (clean start). -Use `npm run demo:seed -- --help` for full usage information. - -### Database location - -The canonical dev database is `backend/src/Taskdeck.Api/taskdeck.db` (SQLite, created by EF Core migration on first backend startup). The connection string is `Data Source=taskdeck.db` in `appsettings.json`, resolved relative to the backend's working directory. - -To reset the database without `--reset` (which only deletes demo boards via the API): - -```bash -cd frontend/taskdeck-web -npm run demo:reset-db # delete canonical dev DB -npm run demo:reset-db -- --all # also delete e2e/demo/ci DB files -``` - -Then restart the backend — EF Core will recreate the DB from migrations. - -Other DB files in the repo are per-purpose: -- `taskdeck.e2e*.db` — E2E test databases (Playwright) -- `taskdeck.demo*.db` — demo director/CI databases -- `backend/tests/**/taskdeck.db` — backend test databases (created by test runs) -- `taskdeck.db` at repo root — created when the backend is started from the repo root (e.g. `dotnet run --project backend/src/Taskdeck.Api/...`). Safe to delete when the backend is stopped and your active dev DB is `backend/src/Taskdeck.Api/taskdeck.db`. - -## Managed-Key Mode Disclosure - -When running demos with a platform-managed LLM provider key (any configuration where `Llm__Provider` is set to `OpenAI` or `Gemini` with a shared key), presenters should be aware: - -- User chat messages and capture content are sent to the configured third-party provider -- Per-user quota limits apply (default: 60 requests/hour, 100K tokens/day) -- Operator kill switches can throttle or block LLM access per user, per surface, or globally - -Full policy details: `docs/security/MANAGED_KEY_USAGE_POLICY.md` - -## Runtime Preconditions - -- Demo scripts are local-safe by default. They target `http://localhost:5000/api` unless you override `TASKDECK_API_BASE_URL` or `TASKDECK_E2E_API_BASE_URL`. -- Non-local API targets are rejected unless you explicitly set `TASKDECK_DEMO_ALLOW_NON_LOCAL_API=1`. -- UI links and Playwright bootstrap default to `http://localhost:5173`; local fallback ports `4173` and `5001` are also supported. -- Demo harness credentials default to `demo` / `demo123` and `collab` / `demo123` unless you override the `TASKDECK_DEMO_*` / `TASKDECK_COLLAB_*` environment variables. -- Full Playwright-backed demos (`demo:director` or the opt-in stakeholder recorder) now auto-enable a live provider when LLM steps are enabled and a usable key is present. -- Gemini is preferred for full demos when `GEMINI_API_KEY`, `TASKDECK_DEMO_GEMINI_API_KEY`, or `Llm__Gemini__ApiKey` is set. Use `TASKDECK_DEMO_LLM_PROVIDER=OpenAI` to force OpenAI instead. -- Demo-specific live keys now take effect even when the base development environment is pinned to `Llm__Provider=Mock`; use `TASKDECK_DEMO_LLM_PROVIDER=Mock` or `TASKDECK_DEMO_DISABLE_LIVE_LLM=1` to force mock instead. -- When a full demo injects live-provider overrides, Playwright also disables existing-server reuse by default so the intended backend process is launched instead of silently inheriting a stale mock server. -- `taskdeck-chat` autopilot and scenario steps marked `requiresLlm: true` still need a usable live-provider key. Use `--skip-llm` for deterministic local or CI runs. -- `demo:director` and the stakeholder recorder require Playwright Chromium (`npx playwright install chromium`) and write access to `frontend/taskdeck-web/demo-artifacts/`. -- `demo:director:smoke` also owns a dedicated Playwright/demo database (`frontend/taskdeck-web/taskdeck.demo.ci.db`) and forces fresh backend/frontend startup so repeated runs do not inherit local `taskdeck.e2e.db` state. -- In fresh-server mode, the director keeps `http://localhost:5000/api` when it is free and otherwise auto-selects a free local API port before starting the backend. -- Unknown scenario IDs now fail fast during director/recorder setup so autopilot and walkthrough selection do not silently target the engineering board by fallback. -- Director-specific flags must appear before `--`; anything after `--` is forwarded to Playwright unchanged. Unknown director flags now fail fast instead of being silently forwarded. - -## Scenario Harness - -Scenario reference: [SCENARIOS.md](SCENARIOS.md) - -List scenarios: - -```bash -cd frontend/taskdeck-web -npm run demo:run -- --list -``` - -Run scenarios: - -```bash -npm run demo:run -- engineering-sprint -npm run demo:run -- support-triage -npm run demo:run -- content-calendar -npm run demo:run -- --clean engineering-sprint -npm run demo:run -- --clean --dry-run engineering-sprint -``` - -JSON-runner flags: - -```bash -# skip default LLM-dependent steps and any step marked requiresLlm: true -npm run demo:run -- support-triage --skip-llm - -# keep running after a failed step -npm run demo:run -- engineering-sprint --continue-on-error -``` - -Autopilot simulation: - -```bash -npm run demo:autopilot -- --turns 5 --brain heuristic -``` - -Deterministic autopilot simulation (seeded): - -```bash -npm run demo:autopilot -- --turns 5 --brain heuristic --rng-seed 42 -``` - -Optional chat-driven autopilot (requires live provider setup): - -```bash -npm run demo:autopilot -- --turns 5 --brain taskdeck-chat -``` - -Loop-specific autopilot runs: - -```bash -npm run demo:autopilot -- --loop queue -npm run demo:autopilot -- --loop capture -npm run demo:autopilot -- --loop mixed -``` - -## 5-Minute Stakeholder Flow - -Saul-facing default (recording path): - -1. Home -- Confirm the product teaches `Inbox -> Review -> Board`. -- Open the `DEMO: Client Onboarding Demo` board path. - -2. Inbox/Capture -- Show ACME capture lineage and the proposal handoff action. - -3. Review -- Confirm review-first trust cues (`nothing changes until approval`). -- Show the proposal in business wording and apply deliberately. - -4. Board -- Show the clean onboarding reveal on `DEMO: Client Onboarding Demo`. - -Extended walkthrough (optional): - -1. Boards -- Open `DEMO: Client Onboarding Demo`. -- Explain reviewed proposals are the mutation gate. - -2. Inbox -- Show ignored and triaged items. -- Follow provenance from capture item to proposal. - -3. Automations -> Proposals -- Show pending/applied proposals. -- Explain review-first safety and explicit operations. - -4. Notifications -- Show mention and proposal outcome notifications. - -5. Activity and Ops (optional) -- Show audit/activity events. -- Show seeded Ops runs/log entries. - -## MVP Dogfooding Loop - -1. Capture in Inbox. -2. Start triage. -3. Review in Proposals. -4. Approve and execute. -5. Continue board execution. - -## Why Some Pages Start Empty - -These surfaces are event-driven: - -- `Activity` needs audit events. -- `Notifications` needs mentions/proposal outcomes. -- `Ops -> Logs` needs Ops runs. -- `Access` needs board-specific entries. - -Use `npm run demo:seed` and/or `npm run demo:run` before a manual walkthrough. - -## Feature Flags for Demos - -`Activity`, `Ops`, `Access`, and `Archive` are default-off on first run. -Enable them in `Settings -> Feature Flags` when needed for walkthrough coverage. - -## API Walkthrough (No UI) - -Use: - -- `demo/http/taskdeck-demo.http` - -It is designed for VS Code REST Client and exercises register/login, board creation, capture triage, queue, proposals, and Ops templates. - -## Stakeholder Recorder (Opt-In Playwright) - -Spec: - -- `frontend/taskdeck-web/tests/e2e/stakeholder-demo.spec.ts` - -Skipped by default. Run only when explicitly requested. - -PowerShell: - -```powershell -$env:TASKDECK_RUN_DEMO='1' -cd frontend/taskdeck-web -npx playwright test tests/e2e/stakeholder-demo.spec.ts --headed -``` - -Bash: - -```bash -TASKDECK_RUN_DEMO=1 npx playwright test tests/e2e/stakeholder-demo.spec.ts --headed -``` - -CI policy: - -- `stakeholder-demo.spec.ts` remains opt-in only. Default Playwright CI lanes set `TASKDECK_RUN_DEMO=0` and do not execute the recorder. -- Use the deterministic smoke command below for explicit regression proof instead of adding the full recorder to required CI. - -## Demo Director (Scenario -> Autopilot -> Recorder -> Artifacts) - -For a one-command, repeatable stakeholder run (including artifacts), use: - -```bash -cd frontend/taskdeck-web - -# Full run with deterministic autopilot seed -npm run demo:director -- --scenario engineering-sprint --turns 18 --brain heuristic --loop mixed --rng-seed demo-1 - -# Saul-facing deterministic rehearsal artifacts (no autoplay turns) -npm run demo:director -- --output-dir ./demo-artifacts/saul-rehearsal --e2e-db ./taskdeck.demo.saul.db --reset-e2e-db --fresh-servers --scenario client-onboarding --skip-llm --turns 0 --rng-seed saul-rehearsal - -# CI-style deterministic run without LLM-required steps -npm run demo:director -- --scenario engineering-sprint --turns 12 --skip-llm --rng-seed ci-1 - -# Headed run if you want to watch the clickthrough -npm run demo:director -- --scenario engineering-sprint --turns 10 --headed - -# Forward Playwright flags after `--` -npm run demo:director -- --scenario engineering-sprint --turns 10 -- --project=chromium - -# Deterministic smoke path used for explicit CI/manual regression proof -npm run demo:director:smoke -``` - -Artifacts are written to: - -```text -frontend/taskdeck-web/demo-artifacts/run-/ - README.md - run-summary.json - snapshot.json - trace.ndjson - logs/ - screenshots/ - playwright/ -``` - -`trace.ndjson` contains structured scenario/autopilot events and is useful for debugging failed demo runs. - -`demo:director:smoke` writes to `frontend/taskdeck-web/demo-artifacts/ci-smoke/`, resets `frontend/taskdeck-web/taskdeck.demo.ci.db`, auto-selects a free local API port when `5000` is occupied, and disables Playwright server reuse so artifact upload paths and seeded board state stay stable across reruns. - -If startup still fails because you forced conflicting overrides, the director now prints a remediation hint that points to `TASKDECK_E2E_API_BASE_URL` and `TASKDECK_E2E_FRONTEND_PORT`. - -When LLM steps are enabled, the full director flow will automatically pass live-provider settings to the backend web server when a usable demo key is present. Smoke runs still stay deterministic because `--skip-llm` suppresses that auto-enable path. - -If you override the board name (`--autopilot-board` or equivalent env), the recorder walkthrough now follows that same selected board instead of falling back to the scenario default board during the UI clickthrough. - -## Demo CI Policy - -- Required CI and nightly Playwright lanes stay focused on baseline product regressions and explicitly keep the stakeholder recorder off. -- `ci-extended.yml` exposes `demo-director-smoke` as an opt-in lane via `workflow_dispatch` or a PR labeled `automation` when the PR touches `.github/workflows/**`, `backend/**`, `frontend/**`, `deploy/**`, or `scripts/**`. -- Full demo walkthrough recording stays manual/headed by default; use `TASKDECK_RUN_DEMO=1` only when you intentionally want the recorder. - -## Constraints - -Treat these as advanced or diagnostic surfaces in MVP demos: - -- Ops -- Activity -- Access -- Archive - -Primary narrative remains capture-to-proposal with explicit review. +# Taskdeck Demo Playbook + +Taskdeck has a lot of capability under the hood. If you click around a fresh instance, some pages look empty because they are event-driven and only populate after specific flows. + +This playbook gives you: + +1. A one-command seed so the UI starts populated. +2. Scenario harness commands for repeatable demos. +3. A short stakeholder flow and an opt-in recorder. + +Use [START_HERE.md](../START_HERE.md) first if you are trying to understand the product. +This playbook is for seeded demos, stakeholder walkthroughs, and regression/operator use, not the main onboarding path. + +Core story: + +Capture -> Triage -> Proposal -> Apply -> Board + +Saul-facing recording contract: +- `docs/product/SAUL_DEMO_REHEARSAL_CONTRACT.md` + +## Quick Start + +1. Start backend + +```bash +cd backend/src/Taskdeck.Api +dotnet run +``` + +2. Start frontend + +```bash +cd frontend/taskdeck-web +npm install +npm run dev +``` + +Default URLs: + +- API: `http://localhost:5000/api` +- UI: `http://localhost:5173` +- Local fallback ports for UI: `http://localhost:4173`, `http://localhost:5001` +- Health-check endpoints (note: these are **not** under the `/api` prefix): + - `http://localhost:5000/health/live` — liveness probe + - `http://localhost:5000/health/ready` — readiness probe + +3. Seed baseline demo data + +```bash +cd frontend/taskdeck-web +npm run demo:seed +``` + +The seeder creates demo users, boards, Inbox items, proposals, queue activity, notifications, and Ops logs. +On reruns against the canonical demo account, it now reuses the seeded artifacts it can identify instead of appending a fresh copy of every capture, queue sample, comment, chat session, and Ops evidence item. + +Use `npm run demo:seed -- --reset` to delete all demo boards before seeding (clean start). +Use `npm run demo:seed -- --help` for full usage information. + +### Database location + +The canonical dev database is `backend/src/Taskdeck.Api/taskdeck.db` (SQLite, created by EF Core migration on first backend startup). The connection string is `Data Source=taskdeck.db` in `appsettings.json`, resolved relative to the backend's working directory. + +To reset the database without `--reset` (which only deletes demo boards via the API): + +```bash +cd frontend/taskdeck-web +npm run demo:reset-db # delete canonical dev DB +npm run demo:reset-db -- --all # also delete e2e/demo/ci DB files +``` + +Then restart the backend — EF Core will recreate the DB from migrations. + +Other DB files in the repo are per-purpose: +- `taskdeck.e2e*.db` — E2E test databases (Playwright) +- `taskdeck.demo*.db` — demo director/CI databases +- `backend/tests/**/taskdeck.db` — backend test databases (created by test runs) +- `taskdeck.db` at repo root — created when the backend is started from the repo root (e.g. `dotnet run --project backend/src/Taskdeck.Api/...`). Safe to delete when the backend is stopped and your active dev DB is `backend/src/Taskdeck.Api/taskdeck.db`. + +## Managed-Key Mode Disclosure + +When running demos with a platform-managed LLM provider key (any configuration where `Llm__Provider` is set to `OpenAI` or `Gemini` with a shared key), presenters should be aware: + +- User chat messages and capture content are sent to the configured third-party provider +- Per-user quota limits apply (default: 60 requests/hour, 100K tokens/day) +- Operator kill switches can throttle or block LLM access per user, per surface, or globally + +Full policy details: `docs/security/MANAGED_KEY_USAGE_POLICY.md` + +## Runtime Preconditions + +- Demo scripts are local-safe by default. They target `http://localhost:5000/api` unless you override `TASKDECK_API_BASE_URL` or `TASKDECK_E2E_API_BASE_URL`. +- Non-local API targets are rejected unless you explicitly set `TASKDECK_DEMO_ALLOW_NON_LOCAL_API=1`. +- UI links and Playwright bootstrap default to `http://localhost:5173`; local fallback ports `4173` and `5001` are also supported. +- Demo harness credentials default to `demo` / `demo123` and `collab` / `demo123` unless you override the `TASKDECK_DEMO_*` / `TASKDECK_COLLAB_*` environment variables. +- Full Playwright-backed demos (`demo:director` or the opt-in stakeholder recorder) now auto-enable a live provider when LLM steps are enabled and a usable key is present. +- Gemini is preferred for full demos when `GEMINI_API_KEY`, `TASKDECK_DEMO_GEMINI_API_KEY`, or `Llm__Gemini__ApiKey` is set. Use `TASKDECK_DEMO_LLM_PROVIDER=OpenAI` to force OpenAI instead. +- Demo-specific live keys now take effect even when the base development environment is pinned to `Llm__Provider=Mock`; use `TASKDECK_DEMO_LLM_PROVIDER=Mock` or `TASKDECK_DEMO_DISABLE_LIVE_LLM=1` to force mock instead. +- When a full demo injects live-provider overrides, Playwright also disables existing-server reuse by default so the intended backend process is launched instead of silently inheriting a stale mock server. +- `taskdeck-chat` autopilot and scenario steps marked `requiresLlm: true` still need a usable live-provider key. Use `--skip-llm` for deterministic local or CI runs. +- `demo:director` and the stakeholder recorder require Playwright Chromium (`npx playwright install chromium`) and write access to `frontend/taskdeck-web/demo-artifacts/`. +- `demo:director:smoke` also owns a dedicated Playwright/demo database (`frontend/taskdeck-web/taskdeck.demo.ci.db`) and forces fresh backend/frontend startup so repeated runs do not inherit local `taskdeck.e2e.db` state. +- In fresh-server mode, the director keeps `http://localhost:5000/api` when it is free and otherwise auto-selects a free local API port before starting the backend. +- Unknown scenario IDs now fail fast during director/recorder setup so autopilot and walkthrough selection do not silently target the engineering board by fallback. +- Director-specific flags must appear before `--`; anything after `--` is forwarded to Playwright unchanged. Unknown director flags now fail fast instead of being silently forwarded. + +## Scenario Harness + +Scenario reference: [SCENARIOS.md](SCENARIOS.md) + +List scenarios: + +```bash +cd frontend/taskdeck-web +npm run demo:run -- --list +``` + +Run scenarios: + +```bash +npm run demo:run -- engineering-sprint +npm run demo:run -- support-triage +npm run demo:run -- content-calendar +npm run demo:run -- --clean engineering-sprint +npm run demo:run -- --clean --dry-run engineering-sprint +``` + +JSON-runner flags: + +```bash +# skip default LLM-dependent steps and any step marked requiresLlm: true +npm run demo:run -- support-triage --skip-llm + +# keep running after a failed step +npm run demo:run -- engineering-sprint --continue-on-error +``` + +Autopilot simulation: + +```bash +npm run demo:autopilot -- --turns 5 --brain heuristic +``` + +Deterministic autopilot simulation (seeded): + +```bash +npm run demo:autopilot -- --turns 5 --brain heuristic --rng-seed 42 +``` + +Optional chat-driven autopilot (requires live provider setup): + +```bash +npm run demo:autopilot -- --turns 5 --brain taskdeck-chat +``` + +Loop-specific autopilot runs: + +```bash +npm run demo:autopilot -- --loop queue +npm run demo:autopilot -- --loop capture +npm run demo:autopilot -- --loop mixed +``` + +## 5-Minute Stakeholder Flow + +Saul-facing default (recording path): + +1. Home +- Confirm the product teaches `Inbox -> Review -> Board`. +- Open the `DEMO: Client Onboarding Demo` board path. + +2. Inbox/Capture +- Show ACME capture lineage and the proposal handoff action. + +3. Review +- Confirm review-first trust cues (`nothing changes until approval`). +- Show the proposal in business wording and apply deliberately. + +4. Board +- Show the clean onboarding reveal on `DEMO: Client Onboarding Demo`. + +Extended walkthrough (optional): + +1. Boards +- Open `DEMO: Client Onboarding Demo`. +- Explain reviewed proposals are the mutation gate. + +2. Inbox +- Show ignored and triaged items. +- Follow provenance from capture item to proposal. + +3. Automations -> Proposals +- Show pending/applied proposals. +- Explain review-first safety and explicit operations. + +4. Notifications +- Show mention and proposal outcome notifications. + +5. Activity and Ops (optional) +- Show audit/activity events. +- Show seeded Ops runs/log entries. + +## MVP Dogfooding Loop + +1. Capture in Inbox. +2. Start triage. +3. Review in Proposals. +4. Approve and execute. +5. Continue board execution. + +## Why Some Pages Start Empty + +These surfaces are event-driven: + +- `Activity` needs audit events. +- `Notifications` needs mentions/proposal outcomes. +- `Ops -> Logs` needs Ops runs. +- `Access` needs board-specific entries. + +Use `npm run demo:seed` and/or `npm run demo:run` before a manual walkthrough. + +## Feature Flags for Demos + +`Activity`, `Ops`, `Access`, and `Archive` are default-off on first run. +Enable them in `Settings -> Feature Flags` when needed for walkthrough coverage. + +## API Walkthrough (No UI) + +Use: + +- `demo/http/taskdeck-demo.http` + +It is designed for VS Code REST Client and exercises register/login, board creation, capture triage, queue, proposals, and Ops templates. + +## Stakeholder Recorder (Opt-In Playwright) + +Spec: + +- `frontend/taskdeck-web/tests/e2e/stakeholder-demo.spec.ts` + +Skipped by default. Run only when explicitly requested. + +PowerShell: + +```powershell +$env:TASKDECK_RUN_DEMO='1' +cd frontend/taskdeck-web +npx playwright test tests/e2e/stakeholder-demo.spec.ts --headed +``` + +Bash: + +```bash +TASKDECK_RUN_DEMO=1 npx playwright test tests/e2e/stakeholder-demo.spec.ts --headed +``` + +CI policy: + +- `stakeholder-demo.spec.ts` remains opt-in only. Default Playwright CI lanes set `TASKDECK_RUN_DEMO=0` and do not execute the recorder. +- Use the deterministic smoke command below for explicit regression proof instead of adding the full recorder to required CI. + +## Demo Director (Scenario -> Autopilot -> Recorder -> Artifacts) + +For a one-command, repeatable stakeholder run (including artifacts), use: + +```bash +cd frontend/taskdeck-web + +# Full run with deterministic autopilot seed +npm run demo:director -- --scenario engineering-sprint --turns 18 --brain heuristic --loop mixed --rng-seed demo-1 + +# Saul-facing deterministic rehearsal artifacts (no autoplay turns) +npm run demo:director -- --output-dir ./demo-artifacts/saul-rehearsal --e2e-db ./taskdeck.demo.saul.db --reset-e2e-db --fresh-servers --scenario client-onboarding --skip-llm --turns 0 --rng-seed saul-rehearsal + +# CI-style deterministic run without LLM-required steps +npm run demo:director -- --scenario engineering-sprint --turns 12 --skip-llm --rng-seed ci-1 + +# Headed run if you want to watch the clickthrough +npm run demo:director -- --scenario engineering-sprint --turns 10 --headed + +# Forward Playwright flags after `--` +npm run demo:director -- --scenario engineering-sprint --turns 10 -- --project=chromium + +# Deterministic smoke path used for explicit CI/manual regression proof +npm run demo:director:smoke +``` + +Artifacts are written to: + +```text +frontend/taskdeck-web/demo-artifacts/run-/ + README.md + run-summary.json + snapshot.json + trace.ndjson + logs/ + screenshots/ + playwright/ +``` + +`trace.ndjson` contains structured scenario/autopilot events and is useful for debugging failed demo runs. + +`demo:director:smoke` writes to `frontend/taskdeck-web/demo-artifacts/ci-smoke/`, resets `frontend/taskdeck-web/taskdeck.demo.ci.db`, auto-selects a free local API port when `5000` is occupied, and disables Playwright server reuse so artifact upload paths and seeded board state stay stable across reruns. + +If startup still fails because you forced conflicting overrides, the director now prints a remediation hint that points to `TASKDECK_E2E_API_BASE_URL` and `TASKDECK_E2E_FRONTEND_PORT`. + +When LLM steps are enabled, the full director flow will automatically pass live-provider settings to the backend web server when a usable demo key is present. Smoke runs still stay deterministic because `--skip-llm` suppresses that auto-enable path. + +If you override the board name (`--autopilot-board` or equivalent env), the recorder walkthrough now follows that same selected board instead of falling back to the scenario default board during the UI clickthrough. + +## Demo CI Policy + +- Required CI and nightly Playwright lanes stay focused on baseline product regressions and explicitly keep the stakeholder recorder off. +- `ci-extended.yml` exposes `demo-director-smoke` as an opt-in lane via `workflow_dispatch` or a PR labeled `automation` when the PR touches `.github/workflows/**`, `backend/**`, `frontend/**`, `deploy/**`, or `scripts/**`. +- Full demo walkthrough recording stays manual/headed by default; use `TASKDECK_RUN_DEMO=1` only when you intentionally want the recorder. + +## Constraints + +Treat these as advanced or diagnostic surfaces in MVP demos: + +- Ops +- Activity +- Access +- Archive + +Primary narrative remains capture-to-proposal with explicit review. diff --git a/docs/product/FIRST_RUN_WORKFLOWS.md b/docs/product/FIRST_RUN_WORKFLOWS.md index cb4ad3baa..35d6c379a 100644 --- a/docs/product/FIRST_RUN_WORKFLOWS.md +++ b/docs/product/FIRST_RUN_WORKFLOWS.md @@ -1,120 +1,120 @@ -# First-Run Workflows - -This guide covers the shortest real workflows for the shipped novice-first shell. - -Use [../START_HERE.md](../START_HERE.md) first if you want the quick orientation. -Use [HELP_AND_FAQ.md](HELP_AND_FAQ.md) when a page is confusing or unexpectedly empty. - -## Workflow 1: Reach A Useful Board Fast - -Use this when you are brand new to the app. - -1. Open `Home`. -2. If there is no board yet, choose the setup action from `Home` or `Today`. -3. Name the board. -4. Pick one setup shape: - - `Blank board` if you want to shape the workflow yourself - - `Engineering sprint` for software delivery work - - `Support triage` for incoming issue queues - - `Content calendar` for editorial or publishing workflows -5. Open the new board. -6. Add one card directly or drop a note into `Inbox` if the work is still messy. - -What success looks like: - -- you have one board you can actually work in -- you know where `Review` sits before automation touches anything - -## Workflow 2: Turn A Messy Note Into Board Work - -Use this when the input is not yet structured enough to become a card by hand. - -1. Start from `Home`, `Today`, or quick capture. -2. Save the note into `Inbox`. -3. Open the Inbox item and start triage. -4. Wait for the proposal to appear in `Review`. -5. Read the proposed operations carefully. -6. Approve and execute only if the change is correct. -7. Open the linked board and continue the work there. - -What success looks like: - -- the input was captured without losing context -- the board change happened through `Review`, not silently -- the resulting work is visible on the board - -Common mistake: - -- skipping straight to advanced `Queue` or `Chat` when `Inbox -> Review` would be simpler - -## Workflow 3: Reset The Day - -Use this when you already have work underway and need to decide what matters today. - -1. Open `Home` to see captures, pending review, and recent boards. -2. Open `Today`. -3. Check the `Review queue` section first. -4. Check overdue, due-today, and blocked cards next. -5. Use the recommended actions to jump into `Review`, `Inbox`, or the relevant board. -6. Return to the board once the change is decided. - -What success looks like: - -- you did not have to guess which page mattered first -- pending proposals were decided before board work drifted -- blocked or overdue items were visible early - -## Workflow 4: Recover When The Loop Feels Unclear - -Use this when you are unsure what to click next. - -1. Go back to `Home`. -2. Replay the onboarding or setup guidance if it was dismissed. -3. Open `Today` if you need the next concrete action. -4. Open `Review` if you suspect a proposal is already waiting. -5. Open `Inbox` if the work only exists as a note so far. -6. Open the board only after the work is ready to be executed there. - -Fallback rule: - -- if you feel forced into `Queue`, `Ops`, or raw route knowledge for ordinary work, you are probably on the wrong surface - -## Workflow 5: Continue Work After Review - -Use this when a proposal has already been executed. - -1. From `Review`, follow the board-aware link or go straight to the affected board. -2. Confirm the new or updated cards landed where you expected. -3. Move the work forward on the board. -4. Add comments, labels, due dates, or blockers as needed. -5. Capture any follow-up items back into `Inbox` instead of holding them in your head. - -What success looks like: - -- the board remains the visible place where work gets finished -- automation prepared the work, but did not replace the board - -## Normal User Path vs Advanced Paths - -Normal path: - -- `Home` -- `Today` -- `Inbox` -- `Review` -- `Boards` - -Advanced or operator paths: - -- `Chat` -- `Activity` -- `Notifications` -- `Ops` -- `Access` -- `Archive` - -Use the advanced paths when you specifically need diagnostics, collaboration evidence, or manual operator control. - -## Managed-Key LLM Mode Notice - -If your Taskdeck instance uses a platform-managed LLM provider key (rather than your own), fair-use limits and privacy disclosures apply to Chat and capture triage features. See `docs/security/MANAGED_KEY_USAGE_POLICY.md` for the full policy. +# First-Run Workflows + +This guide covers the shortest real workflows for the shipped novice-first shell. + +Use [../START_HERE.md](../START_HERE.md) first if you want the quick orientation. +Use [HELP_AND_FAQ.md](HELP_AND_FAQ.md) when a page is confusing or unexpectedly empty. + +## Workflow 1: Reach A Useful Board Fast + +Use this when you are brand new to the app. + +1. Open `Home`. +2. If there is no board yet, choose the setup action from `Home` or `Today`. +3. Name the board. +4. Pick one setup shape: + - `Blank board` if you want to shape the workflow yourself + - `Engineering sprint` for software delivery work + - `Support triage` for incoming issue queues + - `Content calendar` for editorial or publishing workflows +5. Open the new board. +6. Add one card directly or drop a note into `Inbox` if the work is still messy. + +What success looks like: + +- you have one board you can actually work in +- you know where `Review` sits before automation touches anything + +## Workflow 2: Turn A Messy Note Into Board Work + +Use this when the input is not yet structured enough to become a card by hand. + +1. Start from `Home`, `Today`, or quick capture. +2. Save the note into `Inbox`. +3. Open the Inbox item and start triage. +4. Wait for the proposal to appear in `Review`. +5. Read the proposed operations carefully. +6. Approve and execute only if the change is correct. +7. Open the linked board and continue the work there. + +What success looks like: + +- the input was captured without losing context +- the board change happened through `Review`, not silently +- the resulting work is visible on the board + +Common mistake: + +- skipping straight to advanced `Queue` or `Chat` when `Inbox -> Review` would be simpler + +## Workflow 3: Reset The Day + +Use this when you already have work underway and need to decide what matters today. + +1. Open `Home` to see captures, pending review, and recent boards. +2. Open `Today`. +3. Check the `Review queue` section first. +4. Check overdue, due-today, and blocked cards next. +5. Use the recommended actions to jump into `Review`, `Inbox`, or the relevant board. +6. Return to the board once the change is decided. + +What success looks like: + +- you did not have to guess which page mattered first +- pending proposals were decided before board work drifted +- blocked or overdue items were visible early + +## Workflow 4: Recover When The Loop Feels Unclear + +Use this when you are unsure what to click next. + +1. Go back to `Home`. +2. Replay the onboarding or setup guidance if it was dismissed. +3. Open `Today` if you need the next concrete action. +4. Open `Review` if you suspect a proposal is already waiting. +5. Open `Inbox` if the work only exists as a note so far. +6. Open the board only after the work is ready to be executed there. + +Fallback rule: + +- if you feel forced into `Queue`, `Ops`, or raw route knowledge for ordinary work, you are probably on the wrong surface + +## Workflow 5: Continue Work After Review + +Use this when a proposal has already been executed. + +1. From `Review`, follow the board-aware link or go straight to the affected board. +2. Confirm the new or updated cards landed where you expected. +3. Move the work forward on the board. +4. Add comments, labels, due dates, or blockers as needed. +5. Capture any follow-up items back into `Inbox` instead of holding them in your head. + +What success looks like: + +- the board remains the visible place where work gets finished +- automation prepared the work, but did not replace the board + +## Normal User Path vs Advanced Paths + +Normal path: + +- `Home` +- `Today` +- `Inbox` +- `Review` +- `Boards` + +Advanced or operator paths: + +- `Chat` +- `Activity` +- `Notifications` +- `Ops` +- `Access` +- `Archive` + +Use the advanced paths when you specifically need diagnostics, collaboration evidence, or manual operator control. + +## Managed-Key LLM Mode Notice + +If your Taskdeck instance uses a platform-managed LLM provider key (rather than your own), fair-use limits and privacy disclosures apply to Chat and capture triage features. See `docs/security/MANAGED_KEY_USAGE_POLICY.md` for the full policy. diff --git a/docs/product/HELP_AND_FAQ.md b/docs/product/HELP_AND_FAQ.md index a277ee38e..bd8e115cd 100644 --- a/docs/product/HELP_AND_FAQ.md +++ b/docs/product/HELP_AND_FAQ.md @@ -1,251 +1,251 @@ -# Help And FAQ - -This is the help-center baseline for the shipped product shell. - -Use [FIRST_RUN_WORKFLOWS.md](FIRST_RUN_WORKFLOWS.md) for step-by-step journeys. -Use [../USER_MANUAL.md](../USER_MANUAL.md) for the broader shipped-product reference. - -## Home - -What this page is for: - -- resetting the product loop -- seeing the current workload at a glance -- restarting setup when the flow feels unclear - -When should I use it? - -- at the start of the day -- after signing in -- when you are not sure whether to go to `Today`, `Inbox`, `Review`, or a board - -What do I do if it is empty? - -- create the first board -- replay setup if onboarding was dismissed -- add one capture so the loop has something to process - -Common mistakes: - -- treating `Home` like a dashboard you can ignore after first use -- jumping straight into advanced pages before the core loop is clear - -## Today - -What this page is for: - -- shaping the day -- checking review work before board work -- seeing overdue, due-today, and blocked cards in one place - -When should I use it? - -- when work already exists and you need the next concrete action -- when you want to resume the onboarding path - -What do I do if it is empty? - -- finish the setup path so the workspace has a board -- triage something in `Inbox` -- return to `Home` if you still need the broad reset view - -Common mistakes: - -- using `Today` as a replacement for board execution instead of a daily guide -- ignoring the review queue section and jumping straight to card work - -## Inbox - -What this page is for: - -- storing messy notes, follow-ups, and rough plans -- turning capture into something reviewable - -When should I use it? - -- when the work is not structured enough to become a board change yet -- when you need to save context immediately - -What do I do if it is empty? - -- use quick capture or create one Inbox item -- come back after triage creates proposals if you expected follow-through - -Common mistakes: - -- over-organizing before capturing -- expecting Inbox itself to be the approval step instead of `Review` - -## Review - -What this page is for: - -- deciding proposed changes before they touch a board -- approving, rejecting, or executing work prepared by the system - -When should I use it? - -- after triage runs -- when `Home` or `Today` says proposals are waiting -- when you need the trust boundary - -What do I do if it is empty? - -- open `Inbox` and triage something first -- return to `Home` or `Today` if you are checking the rest of the loop - -Common mistakes: - -- expecting proposals to apply themselves -- using advanced `Queue` as the normal approval path - -## Boards - -What this page is for: - -- visible execution -- card movement, editing, and collaboration -- continuing the work after review - -When should I use it? - -- once the work is ready to be acted on -- when you need to update card state, add comments, or track blockers - -What do I do if it is empty? - -- create the first board -- apply a starter pack if you want a faster starting shape - -Common mistakes: - -- trying to use boards as the only intake surface -- expecting boards to explain pending proposals without going through `Review` - -## Notifications - -What this page is for: - -- seeing mentions and proposal outcome updates - -When should I use it? - -- when you are following up on collaboration or asynchronous review outcomes - -What do I do if it is empty? - -- that usually means nothing has mentioned you yet and no proposal outcome targeted you - -Common mistakes: - -- treating notifications as the main place to manage work instead of a follow-up signal - -## Chat - -What this page is for: - -- board-scoped conversational help -- manual operator-style automation follow-up - -When should I use it? - -- when you specifically want a conversational flow -- when `Inbox` and `Review` are not enough for the job - -What do I do if it feels too advanced? - -- return to `Inbox` for normal intake -- return to `Review` for the normal proposal path - -Common mistakes: - -- using `Chat` as the default starting path for ordinary work - -## Activity - -What this page is for: - -- inspecting what already happened -- checking board, entity, or user history - -When should I use it? - -- when you need evidence, traceability, or audit-style context - -What do I do if it is empty? - -- widen the filters -- confirm the relevant board already has meaningful activity - -Common mistakes: - -- going to Activity when what you really need is a pending decision in `Review` - -## Ops - -What this page is for: - -- diagnostics -- logs -- endpoint and CLI exploration - -When should I use it? - -- when you are operating or troubleshooting the system - -What do I do if it feels too technical? - -- leave it alone for normal product use -- return to `Home`, `Today`, `Inbox`, `Review`, or a board - -Common mistakes: - -- assuming Ops is part of the ordinary first-run path - -## Access And Archive - -Use `Access` when: - -- you are managing board sharing and roles - -Use `Archive` when: - -- you need to restore or inspect archived boards - -Common mistake: - -- treating either of these as part of normal daily capture or review work - -## FAQ - -### Why do I land on Home instead of Boards? - -Because the shipped shell now starts from the product loop, not from the implementation-shaped board list. `Home` is meant to reduce route-hunting. - -### Is Review the same as the old proposals page? - -Yes. `Review` is the user-facing route and language for the proposals workflow. Legacy automation routes still exist for compatibility, but the intended normal path is `Review`. - -### Why does the UI still say Boards if the docs talk about projects? - -The shipped route label is still `Boards`. The docs use `project` as the product mental model so the help-center can grow cleanly without pretending a renamed route already shipped. - -### What is the difference between Guided and Workbench? - -`Guided` keeps the core loop prominent. `Workbench` keeps more tools visible all the time. They are presentation preferences, not permission boundaries. - -### What does Agent mode do today? - -It preserves the same shipped review-first loop and prepares the mental model for later agent work. It does not mean standalone `Agents`, `Runs`, or `Knowledge` pages are already available. - -### Why is a page empty right after I sign in? - -Most empty states mean the loop has not started yet. Create a board, capture one item, triage it, and then revisit the page. - -### Why did nothing change after capture? - -Capture stores the input. A board change happens only after triage creates a proposal and you explicitly execute it from `Review`. - -### When should I use Queue? - -Only when you intentionally want a manual, power-user instruction path. For ordinary work, start with `Inbox`, then go to `Review`. +# Help And FAQ + +This is the help-center baseline for the shipped product shell. + +Use [FIRST_RUN_WORKFLOWS.md](FIRST_RUN_WORKFLOWS.md) for step-by-step journeys. +Use [../USER_MANUAL.md](../USER_MANUAL.md) for the broader shipped-product reference. + +## Home + +What this page is for: + +- resetting the product loop +- seeing the current workload at a glance +- restarting setup when the flow feels unclear + +When should I use it? + +- at the start of the day +- after signing in +- when you are not sure whether to go to `Today`, `Inbox`, `Review`, or a board + +What do I do if it is empty? + +- create the first board +- replay setup if onboarding was dismissed +- add one capture so the loop has something to process + +Common mistakes: + +- treating `Home` like a dashboard you can ignore after first use +- jumping straight into advanced pages before the core loop is clear + +## Today + +What this page is for: + +- shaping the day +- checking review work before board work +- seeing overdue, due-today, and blocked cards in one place + +When should I use it? + +- when work already exists and you need the next concrete action +- when you want to resume the onboarding path + +What do I do if it is empty? + +- finish the setup path so the workspace has a board +- triage something in `Inbox` +- return to `Home` if you still need the broad reset view + +Common mistakes: + +- using `Today` as a replacement for board execution instead of a daily guide +- ignoring the review queue section and jumping straight to card work + +## Inbox + +What this page is for: + +- storing messy notes, follow-ups, and rough plans +- turning capture into something reviewable + +When should I use it? + +- when the work is not structured enough to become a board change yet +- when you need to save context immediately + +What do I do if it is empty? + +- use quick capture or create one Inbox item +- come back after triage creates proposals if you expected follow-through + +Common mistakes: + +- over-organizing before capturing +- expecting Inbox itself to be the approval step instead of `Review` + +## Review + +What this page is for: + +- deciding proposed changes before they touch a board +- approving, rejecting, or executing work prepared by the system + +When should I use it? + +- after triage runs +- when `Home` or `Today` says proposals are waiting +- when you need the trust boundary + +What do I do if it is empty? + +- open `Inbox` and triage something first +- return to `Home` or `Today` if you are checking the rest of the loop + +Common mistakes: + +- expecting proposals to apply themselves +- using advanced `Queue` as the normal approval path + +## Boards + +What this page is for: + +- visible execution +- card movement, editing, and collaboration +- continuing the work after review + +When should I use it? + +- once the work is ready to be acted on +- when you need to update card state, add comments, or track blockers + +What do I do if it is empty? + +- create the first board +- apply a starter pack if you want a faster starting shape + +Common mistakes: + +- trying to use boards as the only intake surface +- expecting boards to explain pending proposals without going through `Review` + +## Notifications + +What this page is for: + +- seeing mentions and proposal outcome updates + +When should I use it? + +- when you are following up on collaboration or asynchronous review outcomes + +What do I do if it is empty? + +- that usually means nothing has mentioned you yet and no proposal outcome targeted you + +Common mistakes: + +- treating notifications as the main place to manage work instead of a follow-up signal + +## Chat + +What this page is for: + +- board-scoped conversational help +- manual operator-style automation follow-up + +When should I use it? + +- when you specifically want a conversational flow +- when `Inbox` and `Review` are not enough for the job + +What do I do if it feels too advanced? + +- return to `Inbox` for normal intake +- return to `Review` for the normal proposal path + +Common mistakes: + +- using `Chat` as the default starting path for ordinary work + +## Activity + +What this page is for: + +- inspecting what already happened +- checking board, entity, or user history + +When should I use it? + +- when you need evidence, traceability, or audit-style context + +What do I do if it is empty? + +- widen the filters +- confirm the relevant board already has meaningful activity + +Common mistakes: + +- going to Activity when what you really need is a pending decision in `Review` + +## Ops + +What this page is for: + +- diagnostics +- logs +- endpoint and CLI exploration + +When should I use it? + +- when you are operating or troubleshooting the system + +What do I do if it feels too technical? + +- leave it alone for normal product use +- return to `Home`, `Today`, `Inbox`, `Review`, or a board + +Common mistakes: + +- assuming Ops is part of the ordinary first-run path + +## Access And Archive + +Use `Access` when: + +- you are managing board sharing and roles + +Use `Archive` when: + +- you need to restore or inspect archived boards + +Common mistake: + +- treating either of these as part of normal daily capture or review work + +## FAQ + +### Why do I land on Home instead of Boards? + +Because the shipped shell now starts from the product loop, not from the implementation-shaped board list. `Home` is meant to reduce route-hunting. + +### Is Review the same as the old proposals page? + +Yes. `Review` is the user-facing route and language for the proposals workflow. Legacy automation routes still exist for compatibility, but the intended normal path is `Review`. + +### Why does the UI still say Boards if the docs talk about projects? + +The shipped route label is still `Boards`. The docs use `project` as the product mental model so the help-center can grow cleanly without pretending a renamed route already shipped. + +### What is the difference between Guided and Workbench? + +`Guided` keeps the core loop prominent. `Workbench` keeps more tools visible all the time. They are presentation preferences, not permission boundaries. + +### What does Agent mode do today? + +It preserves the same shipped review-first loop and prepares the mental model for later agent work. It does not mean standalone `Agents`, `Runs`, or `Knowledge` pages are already available. + +### Why is a page empty right after I sign in? + +Most empty states mean the loop has not started yet. Create a board, capture one item, triage it, and then revisit the page. + +### Why did nothing change after capture? + +Capture stores the input. A board change happens only after triage creates a proposal and you explicitly execute it from `Review`. + +### When should I use Queue? + +Only when you intentionally want a manual, power-user instruction path. For ordinary work, start with `Inbox`, then go to `Review`. diff --git a/docs/product/README.md b/docs/product/README.md index 3ec20b425..d167453ab 100644 --- a/docs/product/README.md +++ b/docs/product/README.md @@ -1,23 +1,23 @@ -# Product Docs - -This folder contains product-facing guides that help users understand, use, demo, and dogfood Taskdeck without turning the docs root into a mixed bag. - -- `FIRST_RUN_WORKFLOWS.md` -- `HELP_AND_FAQ.md` -- `DEMO_PLAYBOOK.md` -- `DEMO_SCRIPT.md` -- `SAUL_DEMO_REHEARSAL_CONTRACT.md` -- `LANDING_COPY.md` -- `BETA_INTAKE_WORKFLOW.md` -- `DOGFOODING_GUIDE.md` -- `SCENARIOS.md` - -Current-state entry docs still remain at root: - -- `../START_HERE.md` -- `../USER_MANUAL.md` - -Use this split on purpose: - -- root docs explain the shipped product shape and canonical entry path -- `product/` carries reusable workflow guides, page-level help, demo guidance, and scenario material +# Product Docs + +This folder contains product-facing guides that help users understand, use, demo, and dogfood Taskdeck without turning the docs root into a mixed bag. + +- `FIRST_RUN_WORKFLOWS.md` +- `HELP_AND_FAQ.md` +- `DEMO_PLAYBOOK.md` +- `DEMO_SCRIPT.md` +- `SAUL_DEMO_REHEARSAL_CONTRACT.md` +- `LANDING_COPY.md` +- `BETA_INTAKE_WORKFLOW.md` +- `DOGFOODING_GUIDE.md` +- `SCENARIOS.md` + +Current-state entry docs still remain at root: + +- `../START_HERE.md` +- `../USER_MANUAL.md` + +Use this split on purpose: + +- root docs explain the shipped product shape and canonical entry path +- `product/` carries reusable workflow guides, page-level help, demo guidance, and scenario material diff --git a/docs/product/SCENARIOS.md b/docs/product/SCENARIOS.md index 8d2cb6a0b..17aa485ec 100644 --- a/docs/product/SCENARIOS.md +++ b/docs/product/SCENARIOS.md @@ -1,196 +1,196 @@ -# Scenarios (JSON Runner) - +# Scenarios (JSON Runner) + Taskdeck includes a JSON scenario runner for deterministic demo and test setup. Use it to seed boards, cards, captures, queue requests, and proposals without manual UI clicking. Productization note: - prefer scenarios that tell one causal story (`capture -> triage -> proposal -> board`) instead of broad page-tour coverage - the MVP expansion blueprint stages the `novice-first-first-run` scenario shape; treat that shape as the acceptance contract for the shipped first-run smoke path - -Runner files: - -- `frontend/taskdeck-web/scripts/scenario-json-runner.mjs` -- `frontend/taskdeck-web/scripts/scenarios-json/*.json` -- `frontend/taskdeck-web/scripts/scenarios-json/schema.v1.json` - -## Running scenarios - -From `frontend/taskdeck-web`: - -```bash -# list available scenarios -npm run demo:run -- --list - -# run a scenario -npm run demo:run -- engineering-sprint -npm run demo:run -- support-triage -npm run demo:run -- content-calendar - + +Runner files: + +- `frontend/taskdeck-web/scripts/scenario-json-runner.mjs` +- `frontend/taskdeck-web/scripts/scenarios-json/*.json` +- `frontend/taskdeck-web/scripts/scenarios-json/schema.v1.json` + +## Running scenarios + +From `frontend/taskdeck-web`: + +```bash +# list available scenarios +npm run demo:run -- --list + +# run a scenario +npm run demo:run -- engineering-sprint +npm run demo:run -- support-triage +npm run demo:run -- content-calendar + # skip default LLM-dependent steps and any step marked requiresLlm: true npm run demo:run -- support-triage --skip-llm - -# continue after a failed step -npm run demo:run -- engineering-sprint --continue-on-error - -# archive DEMO:* boards first -npm run demo:run -- engineering-sprint --clean -``` - -CI note: - + +# continue after a failed step +npm run demo:run -- engineering-sprint --continue-on-error + +# archive DEMO:* boards first +npm run demo:run -- engineering-sprint --clean +``` + +CI note: + - `--skip-llm` and `--continue-on-error` are for the JSON-runner flow. - Default LLM-dependent step handling covers `queueInstruction`, `triageCapture`, `waitForCaptureProposal`, and `waitForCaptureOutcome`; mark any other model-dependent step with `requiresLlm: true` so it can also be skipped deterministically. - `npm run demo:director:smoke` uses the same policy: deterministic seed, no autopilot turns, LLM-required steps skipped, isolated `taskdeck.demo.ci.db`, and forced fresh Playwright servers. - -Environment overrides: - -- `TASKDECK_API_BASE_URL` (default: `http://localhost:5000/api`) -- `TASKDECK_UI_BASE_URL` (default: `http://localhost:5173`) -- Local fallback UI ports also include `http://localhost:4173` and `http://localhost:5001`. -- Demo scripts reject non-local API targets unless `TASKDECK_DEMO_ALLOW_NON_LOCAL_API=1` is set. - -## Template interpolation - -Any string field can reference previously created aliases via `${...}` interpolation. - -Example: - -```json -{ - "type": "queueInstruction", - "board": "board", - "instruction": "move card ${cards.designEmptyState.id} to column \"Scheduled\"" -} -``` - -Supported namespaces: - -- `boards..*` -- `cards..*` -- `captures..*` -- `proposals..*` -- `queueRequests..*` -- `opsRuns..*` - + +Environment overrides: + +- `TASKDECK_API_BASE_URL` (default: `http://localhost:5000/api`) +- `TASKDECK_UI_BASE_URL` (default: `http://localhost:5173`) +- Local fallback UI ports also include `http://localhost:4173` and `http://localhost:5001`. +- Demo scripts reject non-local API targets unless `TASKDECK_DEMO_ALLOW_NON_LOCAL_API=1` is set. + +## Template interpolation + +Any string field can reference previously created aliases via `${...}` interpolation. + +Example: + +```json +{ + "type": "queueInstruction", + "board": "board", + "instruction": "move card ${cards.designEmptyState.id} to column \"Scheduled\"" +} +``` + +Supported namespaces: + +- `boards..*` +- `cards..*` +- `captures..*` +- `proposals..*` +- `queueRequests..*` +- `opsRuns..*` + If interpolation fails to resolve, the runner throws immediately with the unresolved expression and step location. Unknown scenario IDs/paths also fail fast instead of silently falling back to another scenario. - -## Step types - -### createBoard - -Creates a board and stores it under an alias. - -```json -{ "type": "createBoard", "alias": "board", "name": "DEMO: X", "description": "..." } -``` - -### applyStarterPack - -Applies a starter pack to an existing board alias. - -```json -{ "type": "applyStarterPack", "board": "board", "starterPackId": "board-blueprint-engineering-sprint" } -``` - -### createCard - -Creates a card in a column. Labels and due date are optional. - -```json -{ - "type": "createCard", - "alias": "c1", - "board": "board", - "column": "Backlog", - "title": "Fix bug", - "description": "repro...", - "dueInDays": 2, - "labels": ["bug", "priority-high"] -} -``` - -### updateCard - -Patches a card using the Cards PATCH contract. - -```json -{ - "type": "updateCard", - "board": "board", - "card": "c1", - "patch": { "isBlocked": true, "blockReason": "Waiting on X" } -} -``` - -### moveCard - -Moves a card to another column directly via API. - -```json -{ "type": "moveCard", "board": "board", "card": "c1", "toColumn": "Done" } -``` - -### addComment - -Adds a comment to a card. - -```json -{ "type": "addComment", "board": "board", "card": "c1", "content": "LGTM @collab" } -``` - -### queueInstruction - -Creates a queue request, waits for a proposal, then approves and executes it. - -```json -{ - "type": "queueInstruction", - "board": "board", - "instruction": "create card \"From scenario\" in column \"Backlog\"", - "requestAlias": "q1", - "proposalAlias": "p1" -} -``` - -### createCapture, triageCapture, waitForCaptureProposal, executeProposal - -Capture-loop steps. Triage/proposal execution usually require a live LLM provider. - -```json -{ "type": "createCapture", "alias": "cap1", "board": "board", "text": "Customer says checkout fails..." } -{ "type": "triageCapture", "capture": "cap1", "requiresLlm": true } -{ "type": "waitForCaptureProposal", "capture": "cap1", "proposalAlias": "cap1Proposal", "requiresLlm": true } -{ "type": "executeProposal", "proposal": "cap1Proposal", "requiresLlm": true } -``` - + +## Step types + +### createBoard + +Creates a board and stores it under an alias. + +```json +{ "type": "createBoard", "alias": "board", "name": "DEMO: X", "description": "..." } +``` + +### applyStarterPack + +Applies a starter pack to an existing board alias. + +```json +{ "type": "applyStarterPack", "board": "board", "starterPackId": "board-blueprint-engineering-sprint" } +``` + +### createCard + +Creates a card in a column. Labels and due date are optional. + +```json +{ + "type": "createCard", + "alias": "c1", + "board": "board", + "column": "Backlog", + "title": "Fix bug", + "description": "repro...", + "dueInDays": 2, + "labels": ["bug", "priority-high"] +} +``` + +### updateCard + +Patches a card using the Cards PATCH contract. + +```json +{ + "type": "updateCard", + "board": "board", + "card": "c1", + "patch": { "isBlocked": true, "blockReason": "Waiting on X" } +} +``` + +### moveCard + +Moves a card to another column directly via API. + +```json +{ "type": "moveCard", "board": "board", "card": "c1", "toColumn": "Done" } +``` + +### addComment + +Adds a comment to a card. + +```json +{ "type": "addComment", "board": "board", "card": "c1", "content": "LGTM @collab" } +``` + +### queueInstruction + +Creates a queue request, waits for a proposal, then approves and executes it. + +```json +{ + "type": "queueInstruction", + "board": "board", + "instruction": "create card \"From scenario\" in column \"Backlog\"", + "requestAlias": "q1", + "proposalAlias": "p1" +} +``` + +### createCapture, triageCapture, waitForCaptureProposal, executeProposal + +Capture-loop steps. Triage/proposal execution usually require a live LLM provider. + +```json +{ "type": "createCapture", "alias": "cap1", "board": "board", "text": "Customer says checkout fails..." } +{ "type": "triageCapture", "capture": "cap1", "requiresLlm": true } +{ "type": "waitForCaptureProposal", "capture": "cap1", "proposalAlias": "cap1Proposal", "requiresLlm": true } +{ "type": "executeProposal", "proposal": "cap1Proposal", "requiresLlm": true } +``` + Use `--skip-llm` to skip the default LLM-dependent steps (`queueInstruction`, `triageCapture`, `waitForCaptureProposal`, `waitForCaptureOutcome`) plus any step explicitly marked with `requiresLlm: true`. - -### runOps - -Runs an Ops template and optionally waits for completion (default) and fetches logs. - -```json -{ - "type": "runOps", - "templateName": "health.check", - "parameters": {}, - "includeLogs": true, - "alias": "opsHealth" -} -``` - -Optional fields: -- `wait` (default `true`): set `false` to return immediately after enqueueing. -- `timeoutMs`, `intervalMs`: poll controls when waiting. -- `parameters`: optional object; all values must be strings to match the Ops API contract. - -## Extending the runner - -1. Update `schema.v1.json` (recommended). -2. Add a `case` in `executeStep()` inside `scenario-json-runner.mjs`. -3. Add a minimal scenario file in `scripts/scenarios-json/`. - + +### runOps + +Runs an Ops template and optionally waits for completion (default) and fetches logs. + +```json +{ + "type": "runOps", + "templateName": "health.check", + "parameters": {}, + "includeLogs": true, + "alias": "opsHealth" +} +``` + +Optional fields: +- `wait` (default `true`): set `false` to return immediately after enqueueing. +- `timeoutMs`, `intervalMs`: poll controls when waiting. +- `parameters`: optional object; all values must be strings to match the Ops API contract. + +## Extending the runner + +1. Update `schema.v1.json` (recommended). +2. Add a `case` in `executeStep()` inside `scenario-json-runner.mjs`. +3. Add a minimal scenario file in `scripts/scenarios-json/`. + Keep step semantics deterministic if you want to reuse scenarios in tests. For LLM-driven steps, set `requiresLlm: true` so they can be skipped in CI. Starter-pack-backed scenarios should only reference columns and labels that the applied starter pack actually creates; the frontend unit suite now asserts that shipped JSON scenarios stay aligned with those contracts. diff --git a/frontend/taskdeck-web/playwright.config.ts b/frontend/taskdeck-web/playwright.config.ts index cee5ff938..8edf8a948 100644 --- a/frontend/taskdeck-web/playwright.config.ts +++ b/frontend/taskdeck-web/playwright.config.ts @@ -1,421 +1,421 @@ -import { defineConfig, devices } from '@playwright/test' -import { - buildHttpOrigin, - defaultFrontendHost, - defaultFrontendPort, - parseFrontendHost, - resolveDefaultFrontendPort, -} from './playwright.port-resolution' -import { resolveDemoBackendLlmEnv, resolvePlaywrightBackendLlmEnv } from './playwright.demo-llm' -import { resolveReuseExistingServer } from './playwright.server-reuse' - -const e2eDbPath = process.env.TASKDECK_E2E_DB ?? 'taskdeck.e2e.db' -/* - * SQLite connection options tuned for E2E parallelization (TST-60, #867). - * - * With `fullyParallel: true`, multiple Playwright workers drive the same backend - * process concurrently. Each test uses a distinct user/board (see - * `registerUserSession` in tests/e2e/support/authSession.ts), so the logical test - * data is already isolated. The remaining contention is at the SQLite engine level: - * parallel writes on the same database file can briefly block each other. - * - * - Pooling=True — reuse connection objects (also Microsoft.Data.Sqlite default). - * - Default Timeout=30 — sets ADO.NET SqliteCommand.CommandTimeout to 30 seconds - * (command cancellation, not PRAGMA busy_timeout). Under - * parallel E2E traffic this prevents premature command - * timeouts; SQLite's actual busy-wait behavior depends on - * busy_timeout PRAGMA (default 0ms in Microsoft.Data.Sqlite). - * - * We intentionally do NOT set `Cache=Shared`: shared-cache mode adds internal - * table-level locking that can increase contention (and SQLITE_BUSY frequency) - * in multi-threaded scenarios rather than reduce it. Future work may enable WAL - * (`PRAGMA journal_mode=WAL;`) in the backend for genuine concurrent-read - * throughput; until then the default private-cache mode plus a generous busy - * timeout is the safer default. - * - * These are additive: they do not alter the on-disk format or the test database path, - * and they are scoped to the E2E backend process launched by this config. - */ -const e2eSqliteConnectionOptions = 'Pooling=True;Default Timeout=30' -const e2eConnectionString = `Data Source=${e2eDbPath};${e2eSqliteConnectionOptions}` -const defaultApiBaseUrl = 'http://localhost:5000/api' -const demoBackendLlmEnv = resolveDemoBackendLlmEnv(process.env) -const backendLlmEnv = resolvePlaywrightBackendLlmEnv(process.env) -const reuseExistingServer = resolveReuseExistingServer(process.env, { - requiresFreshServer: Object.keys(demoBackendLlmEnv).length > 0, -}) - -const frontendConfig = resolveFrontendConfig() -const frontendHost = frontendConfig.host -const frontendPort = frontendConfig.port -const frontendBaseUrl = frontendConfig.baseUrl -const apiConfig = resolveApiConfig(process.env.TASKDECK_E2E_API_BASE_URL ?? defaultApiBaseUrl) -const apiBaseUrl = apiConfig.baseUrl - -const backendCorsOrigins = resolveBackendCorsOrigins( - frontendConfig.origin, - process.env.TASKDECK_E2E_API_CORS_ORIGINS, -) -const backendServerEnv: Record = { - ASPNETCORE_ENVIRONMENT: 'Development', - ConnectionStrings__DefaultConnection: e2eConnectionString, - ASPNETCORE_URLS: apiConfig.origin, - ...backendLlmEnv, -} - -for (const [index, origin] of backendCorsOrigins.entries()) { - backendServerEnv[`Cors__DevelopmentAllowedOrigins__${index}`] = origin -} - -/* - * Worker count resolution (TST-60, #867): - * - * CI default is 1 worker — conservative, matches the pre-TST-60 status quo, - * and avoids exposing latent Vue-re-render / Playwright-actionability races - * that surfaced under 2-worker parallel CPU contention (see #867 PR comments - * for the WIP-limit smoke test case). Local default is 2 workers — a modest - * dev-box speedup inside the contention budget this config was tuned for. - * In both cases we intentionally cap below Playwright's own default - * (~50% of logical cores), which fans out well past what a single SQLite - * E2E database can absorb without SQLITE_BUSY bursts. - * - * Override via TASKDECK_E2E_WORKERS if needed (integer >= 1). CI consumers - * that want to adopt parallel runs should flip their workflow env var, so - * any fallout is scoped to the workflow opting in. Shipping parallel-safe - * infrastructure (this config, SQLite connection tuning, per-test user - * isolation, hardened WIP-limit spec) is the TST-60 deliverable; meeting - * the "40% runtime reduction" acceptance criterion requires follow-up work - * on the remaining Vue/actionability races and is tracked for a later PR. - */ -const ciDefaultWorkerCount = 1 -const localDefaultWorkerCount = 2 -const effectiveDefaultWorkerCount = process.env.CI ? ciDefaultWorkerCount : localDefaultWorkerCount -const e2eWorkers = resolveWorkers(process.env.TASKDECK_E2E_WORKERS, effectiveDefaultWorkerCount) - -export default defineConfig({ - testDir: './tests/e2e', - forbidOnly: !!process.env.CI, - /* - * Parallel execution is safe because tests provision unique users, boards, - * columns, and cards per test case (see tests/e2e/support/authSession.ts and - * boardHelpers.ts — names include Date.now() + random suffixes, and data is - * scoped server-side by the authenticated user). The opt-in stakeholder demo - * (stakeholder-demo.spec.ts) is still skipped by default and should remain - * opt-in serial work. - */ - fullyParallel: true, - workers: e2eWorkers, - maxFailures: process.env.CI ? 3 : undefined, - globalTimeout: process.env.CI ? 12 * 60_000 : undefined, - timeout: 45_000, - expect: { - timeout: 8_000, - }, - retries: process.env.CI ? 0 : 0, - reporter: process.env.CI ? [['line'], ['github'], ['html', { open: 'never' }]] : 'list', - /* Exclude quarantined tests from all projects (see docs/testing/FLAKY_TEST_POLICY.md). */ - grepInvert: /@quarantine/, - use: { - baseURL: frontendBaseUrl, - trace: 'retain-on-failure', - }, - - /* --------------------------------------------------------------------------- - * Browser & device projects - * - * Tagging strategy (see docs/testing/FLAKY_TEST_POLICY.md): - * @smoke — quick PR gate (Chromium-only, default) - * @cross-browser — full browser matrix (nightly / manual) - * @mobile — mobile viewport scenarios (nightly / manual) - * - * CI behaviour: - * PR (ci-required) → "chromium" project only (grep excludes @mobile) - * Nightly / manual → all projects via reusable-e2e-cross-browser.yml - * -----------------------------------------------------------------------*/ - projects: [ - /* --- Desktop browsers --- */ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - /* Default project: runs all tests except @mobile-only scenarios. - * Existing untagged tests continue to run here unchanged. - * - * NOTE: @cross-browser tests also run here (in PR gate via ci-required). - * Adding more @cross-browser tests will increase PR gate time. - * Keep @cross-browser count lean to preserve fast PR feedback. - * - * Combined pattern ensures quarantine exclusion is preserved - * (project-level grepInvert overrides the global one). */ - grepInvert: /@mobile|@quarantine/, - }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - /* Only tests explicitly tagged @cross-browser run on Firefox. */ - grep: /@cross-browser/, - }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - /* Only tests explicitly tagged @cross-browser run on WebKit. */ - grep: /@cross-browser/, - }, - /* --- Mobile viewports --- */ - { - name: 'mobile-chrome', - use: { ...devices['Pixel 7'] }, - /* Only tests tagged @mobile run on mobile viewports. */ - grep: /@mobile/, - }, - { - name: 'mobile-safari', - use: { ...devices['iPhone 14'] }, - grep: /@mobile/, - }, - ], - - webServer: [ - { - command: 'dotnet run --no-launch-profile --project ../../backend/src/Taskdeck.Api/Taskdeck.Api.csproj', - url: apiConfig.readinessUrl, - timeout: 120_000, - reuseExistingServer, - stdout: 'pipe', - stderr: 'pipe', - env: backendServerEnv, - }, - { - command: `npm run dev -- --host ${frontendHost} --port ${frontendPort}`, - url: frontendBaseUrl, - timeout: 120_000, - reuseExistingServer, - stdout: 'pipe', - stderr: 'pipe', - env: { - VITE_API_BASE_URL: apiBaseUrl, - }, - }, - ], -}) - -type FrontendConfig = { - baseUrl: string - host: string - origin: string - port: number -} - -type ApiConfig = { - baseUrl: string - origin: string - readinessUrl: string -} - -function resolveFrontendConfig(): FrontendConfig { - const rawFrontendBaseUrl = process.env.TASKDECK_E2E_FRONTEND_BASE_URL - if (rawFrontendBaseUrl && rawFrontendBaseUrl.trim().length > 0) { - return resolveFrontendConfigFromBaseUrl(rawFrontendBaseUrl) - } - - const host = parseFrontendHost( - process.env.TASKDECK_E2E_FRONTEND_HOST ?? defaultFrontendHost, - 'TASKDECK_E2E_FRONTEND_HOST', - ) - const explicitFrontendPort = process.env.TASKDECK_E2E_FRONTEND_PORT - const resolvedFrontendPort = process.env.TASKDECK_E2E_RESOLVED_FRONTEND_PORT - - const port = explicitFrontendPort - ? parsePort(explicitFrontendPort, defaultFrontendPort, 'TASKDECK_E2E_FRONTEND_PORT') - : resolvedFrontendPort - ? parsePort( - resolvedFrontendPort, - defaultFrontendPort, - 'TASKDECK_E2E_RESOLVED_FRONTEND_PORT', - ) - : resolveDefaultFrontendPort(host, { - allowExistingFrontendReuse: reuseExistingServer, - }) - - if (!explicitFrontendPort && !resolvedFrontendPort) { - // Keep runner/worker baseURL aligned by reusing the first resolved port value. - process.env.TASKDECK_E2E_RESOLVED_FRONTEND_PORT = String(port) - } - - const origin = buildHttpOrigin(host, port) - - return { - baseUrl: origin, - host, - origin, - port, - } -} - -function resolveFrontendConfigFromBaseUrl(rawFrontendBaseUrl: string): FrontendConfig { - const parsedFrontendBaseUrl = parseFrontendBaseUrl(rawFrontendBaseUrl) - if (parsedFrontendBaseUrl.port.length === 0) { - throw new Error( - `[e2e config] TASKDECK_E2E_FRONTEND_BASE_URL must include an explicit port (example: "http://localhost:${defaultFrontendPort}"). Received "${rawFrontendBaseUrl}".`, - ) - } - - if (normalizePath(parsedFrontendBaseUrl.pathname).length > 0) { - throw new Error( - `[e2e config] TASKDECK_E2E_FRONTEND_BASE_URL cannot include a path segment. Use an origin only (example: "http://localhost:${defaultFrontendPort}"). Received "${rawFrontendBaseUrl}".`, - ) - } - - if (parsedFrontendBaseUrl.search.length > 0 || parsedFrontendBaseUrl.hash.length > 0) { - throw new Error( - `[e2e config] TASKDECK_E2E_FRONTEND_BASE_URL cannot include query or hash fragments. Received "${rawFrontendBaseUrl}".`, - ) - } - - const port = parsePort( - parsedFrontendBaseUrl.port, - defaultFrontendPort, - 'TASKDECK_E2E_FRONTEND_BASE_URL', - ) - - return { - baseUrl: parsedFrontendBaseUrl.origin, - host: parseFrontendHost(parsedFrontendBaseUrl.hostname, 'TASKDECK_E2E_FRONTEND_BASE_URL'), - origin: parsedFrontendBaseUrl.origin, - port, - } -} - -function parseFrontendBaseUrl(rawFrontendBaseUrl: string): URL { - try { - const parsedFrontendBaseUrl = new URL(rawFrontendBaseUrl) - if (parsedFrontendBaseUrl.protocol !== 'http:') { - throw new Error('Only http:// is supported.') - } - - return parsedFrontendBaseUrl - } catch (error) { - const reason = error instanceof Error ? error.message : 'Invalid URL format.' - throw new Error( - `[e2e config] TASKDECK_E2E_FRONTEND_BASE_URL must be an absolute http URL (example: "http://localhost:${defaultFrontendPort}"). Received "${rawFrontendBaseUrl}". ${reason}`, - { cause: error }, - ) - } -} - -function parsePort(rawPort: string | undefined, fallbackPort: number, source: string): number { - if (!rawPort) { - return fallbackPort - } - - const normalizedPort = rawPort.trim() - if (!/^\d+$/.test(normalizedPort)) { - throw new Error(`[e2e config] ${source} must be an integer between 1 and 65535. Received "${rawPort}".`) - } - - const parsedPort = Number.parseInt(normalizedPort, 10) - if (parsedPort < 1 || parsedPort > 65535) { - throw new Error(`[e2e config] ${source} must be between 1 and 65535. Received "${rawPort}".`) - } - - return parsedPort -} - -function resolveApiConfig(rawApiBaseUrl: string): ApiConfig { - const parsedApiBaseUrl = parseApiBaseUrl(rawApiBaseUrl) - const apiPath = normalizePath(parsedApiBaseUrl.pathname) - if (apiPath.length === 0) { - throw new Error( - `[e2e config] TASKDECK_E2E_API_BASE_URL must include an API path (example: "${defaultApiBaseUrl}"). Received "${rawApiBaseUrl}".`, - ) - } - - const normalizedBaseUrl = `${parsedApiBaseUrl.origin}${apiPath}` - return { - baseUrl: normalizedBaseUrl, - origin: parsedApiBaseUrl.origin, - readinessUrl: `${normalizedBaseUrl}/boards`, - } -} - -function parseApiBaseUrl(rawApiBaseUrl: string): URL { - try { - const parsedApiBaseUrl = new URL(rawApiBaseUrl) - if (parsedApiBaseUrl.protocol !== 'http:') { - throw new Error('Only http:// is supported.') - } - - if (parsedApiBaseUrl.port.length === 0) { - throw new Error('An explicit port is required.') - } - - if (parsedApiBaseUrl.search.length > 0 || parsedApiBaseUrl.hash.length > 0) { - throw new Error('Query and hash fragments are not supported.') - } - - return parsedApiBaseUrl - } catch (error) { - const reason = error instanceof Error ? error.message : 'Invalid URL format.' - throw new Error( - `[e2e config] TASKDECK_E2E_API_BASE_URL must be an absolute http URL with explicit port (example: "${defaultApiBaseUrl}"). Received "${rawApiBaseUrl}". ${reason}`, - { cause: error }, - ) - } -} - -function normalizePath(pathname: string): string { - if (!pathname || pathname === '/') { - return '' - } - - return pathname.replace(/\/+$/, '') -} - -function resolveBackendCorsOrigins(frontendOrigin: string, rawOrigins: string | undefined): string[] { - return dedupeOrigins([frontendOrigin, 'http://localhost:5174', ...parseOriginList(rawOrigins)]) -} - -function parseOriginList(rawOrigins: string | undefined): string[] { - if (!rawOrigins) { - return [] - } - - return dedupeOrigins( - rawOrigins - .split(',') - .map((origin) => origin.trim()) - .filter((origin) => origin.length > 0), - ) -} - -function dedupeOrigins(origins: string[]): string[] { - return [...new Set(origins)] -} - -/** - * Resolve the worker count for the E2E runner. - * - * Priority: - * 1. `TASKDECK_E2E_WORKERS` env var (integer >= 1) when set. - * 2. `fallbackDefault` for every other run (both CI and local), so the - * fully-parallel contention budget is respected in all environments. - */ -function resolveWorkers(rawOverride: string | undefined, fallbackDefault: number): number { - if (rawOverride !== undefined) { - const trimmed = rawOverride.trim() - if (!/^\d+$/.test(trimmed)) { - throw new Error( - `[e2e config] TASKDECK_E2E_WORKERS must be a positive integer. Received "${rawOverride}".`, - ) - } - - const parsed = Number.parseInt(trimmed, 10) - if (parsed < 1) { - throw new Error( - `[e2e config] TASKDECK_E2E_WORKERS must be >= 1. Received "${rawOverride}".`, - ) - } - return parsed - } - - return fallbackDefault -} +import { defineConfig, devices } from '@playwright/test' +import { + buildHttpOrigin, + defaultFrontendHost, + defaultFrontendPort, + parseFrontendHost, + resolveDefaultFrontendPort, +} from './playwright.port-resolution' +import { resolveDemoBackendLlmEnv, resolvePlaywrightBackendLlmEnv } from './playwright.demo-llm' +import { resolveReuseExistingServer } from './playwright.server-reuse' + +const e2eDbPath = process.env.TASKDECK_E2E_DB ?? 'taskdeck.e2e.db' +/* + * SQLite connection options tuned for E2E parallelization (TST-60, #867). + * + * With `fullyParallel: true`, multiple Playwright workers drive the same backend + * process concurrently. Each test uses a distinct user/board (see + * `registerUserSession` in tests/e2e/support/authSession.ts), so the logical test + * data is already isolated. The remaining contention is at the SQLite engine level: + * parallel writes on the same database file can briefly block each other. + * + * - Pooling=True — reuse connection objects (also Microsoft.Data.Sqlite default). + * - Default Timeout=30 — sets ADO.NET SqliteCommand.CommandTimeout to 30 seconds + * (command cancellation, not PRAGMA busy_timeout). Under + * parallel E2E traffic this prevents premature command + * timeouts; SQLite's actual busy-wait behavior depends on + * busy_timeout PRAGMA (default 0ms in Microsoft.Data.Sqlite). + * + * We intentionally do NOT set `Cache=Shared`: shared-cache mode adds internal + * table-level locking that can increase contention (and SQLITE_BUSY frequency) + * in multi-threaded scenarios rather than reduce it. Future work may enable WAL + * (`PRAGMA journal_mode=WAL;`) in the backend for genuine concurrent-read + * throughput; until then the default private-cache mode plus a generous busy + * timeout is the safer default. + * + * These are additive: they do not alter the on-disk format or the test database path, + * and they are scoped to the E2E backend process launched by this config. + */ +const e2eSqliteConnectionOptions = 'Pooling=True;Default Timeout=30' +const e2eConnectionString = `Data Source=${e2eDbPath};${e2eSqliteConnectionOptions}` +const defaultApiBaseUrl = 'http://localhost:5000/api' +const demoBackendLlmEnv = resolveDemoBackendLlmEnv(process.env) +const backendLlmEnv = resolvePlaywrightBackendLlmEnv(process.env) +const reuseExistingServer = resolveReuseExistingServer(process.env, { + requiresFreshServer: Object.keys(demoBackendLlmEnv).length > 0, +}) + +const frontendConfig = resolveFrontendConfig() +const frontendHost = frontendConfig.host +const frontendPort = frontendConfig.port +const frontendBaseUrl = frontendConfig.baseUrl +const apiConfig = resolveApiConfig(process.env.TASKDECK_E2E_API_BASE_URL ?? defaultApiBaseUrl) +const apiBaseUrl = apiConfig.baseUrl + +const backendCorsOrigins = resolveBackendCorsOrigins( + frontendConfig.origin, + process.env.TASKDECK_E2E_API_CORS_ORIGINS, +) +const backendServerEnv: Record = { + ASPNETCORE_ENVIRONMENT: 'Development', + ConnectionStrings__DefaultConnection: e2eConnectionString, + ASPNETCORE_URLS: apiConfig.origin, + ...backendLlmEnv, +} + +for (const [index, origin] of backendCorsOrigins.entries()) { + backendServerEnv[`Cors__DevelopmentAllowedOrigins__${index}`] = origin +} + +/* + * Worker count resolution (TST-60, #867): + * + * CI default is 1 worker — conservative, matches the pre-TST-60 status quo, + * and avoids exposing latent Vue-re-render / Playwright-actionability races + * that surfaced under 2-worker parallel CPU contention (see #867 PR comments + * for the WIP-limit smoke test case). Local default is 2 workers — a modest + * dev-box speedup inside the contention budget this config was tuned for. + * In both cases we intentionally cap below Playwright's own default + * (~50% of logical cores), which fans out well past what a single SQLite + * E2E database can absorb without SQLITE_BUSY bursts. + * + * Override via TASKDECK_E2E_WORKERS if needed (integer >= 1). CI consumers + * that want to adopt parallel runs should flip their workflow env var, so + * any fallout is scoped to the workflow opting in. Shipping parallel-safe + * infrastructure (this config, SQLite connection tuning, per-test user + * isolation, hardened WIP-limit spec) is the TST-60 deliverable; meeting + * the "40% runtime reduction" acceptance criterion requires follow-up work + * on the remaining Vue/actionability races and is tracked for a later PR. + */ +const ciDefaultWorkerCount = 1 +const localDefaultWorkerCount = 2 +const effectiveDefaultWorkerCount = process.env.CI ? ciDefaultWorkerCount : localDefaultWorkerCount +const e2eWorkers = resolveWorkers(process.env.TASKDECK_E2E_WORKERS, effectiveDefaultWorkerCount) + +export default defineConfig({ + testDir: './tests/e2e', + forbidOnly: !!process.env.CI, + /* + * Parallel execution is safe because tests provision unique users, boards, + * columns, and cards per test case (see tests/e2e/support/authSession.ts and + * boardHelpers.ts — names include Date.now() + random suffixes, and data is + * scoped server-side by the authenticated user). The opt-in stakeholder demo + * (stakeholder-demo.spec.ts) is still skipped by default and should remain + * opt-in serial work. + */ + fullyParallel: true, + workers: e2eWorkers, + maxFailures: process.env.CI ? 3 : undefined, + globalTimeout: process.env.CI ? 12 * 60_000 : undefined, + timeout: 45_000, + expect: { + timeout: 8_000, + }, + retries: process.env.CI ? 0 : 0, + reporter: process.env.CI ? [['line'], ['github'], ['html', { open: 'never' }]] : 'list', + /* Exclude quarantined tests from all projects (see docs/testing/FLAKY_TEST_POLICY.md). */ + grepInvert: /@quarantine/, + use: { + baseURL: frontendBaseUrl, + trace: 'retain-on-failure', + }, + + /* --------------------------------------------------------------------------- + * Browser & device projects + * + * Tagging strategy (see docs/testing/FLAKY_TEST_POLICY.md): + * @smoke — quick PR gate (Chromium-only, default) + * @cross-browser — full browser matrix (nightly / manual) + * @mobile — mobile viewport scenarios (nightly / manual) + * + * CI behaviour: + * PR (ci-required) → "chromium" project only (grep excludes @mobile) + * Nightly / manual → all projects via reusable-e2e-cross-browser.yml + * -----------------------------------------------------------------------*/ + projects: [ + /* --- Desktop browsers --- */ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + /* Default project: runs all tests except @mobile-only scenarios. + * Existing untagged tests continue to run here unchanged. + * + * NOTE: @cross-browser tests also run here (in PR gate via ci-required). + * Adding more @cross-browser tests will increase PR gate time. + * Keep @cross-browser count lean to preserve fast PR feedback. + * + * Combined pattern ensures quarantine exclusion is preserved + * (project-level grepInvert overrides the global one). */ + grepInvert: /@mobile|@quarantine/, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + /* Only tests explicitly tagged @cross-browser run on Firefox. */ + grep: /@cross-browser/, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + /* Only tests explicitly tagged @cross-browser run on WebKit. */ + grep: /@cross-browser/, + }, + /* --- Mobile viewports --- */ + { + name: 'mobile-chrome', + use: { ...devices['Pixel 7'] }, + /* Only tests tagged @mobile run on mobile viewports. */ + grep: /@mobile/, + }, + { + name: 'mobile-safari', + use: { ...devices['iPhone 14'] }, + grep: /@mobile/, + }, + ], + + webServer: [ + { + command: 'dotnet run --no-launch-profile --project ../../backend/src/Taskdeck.Api/Taskdeck.Api.csproj', + url: apiConfig.readinessUrl, + timeout: 120_000, + reuseExistingServer, + stdout: 'pipe', + stderr: 'pipe', + env: backendServerEnv, + }, + { + command: `npm run dev -- --host ${frontendHost} --port ${frontendPort}`, + url: frontendBaseUrl, + timeout: 120_000, + reuseExistingServer, + stdout: 'pipe', + stderr: 'pipe', + env: { + VITE_API_BASE_URL: apiBaseUrl, + }, + }, + ], +}) + +type FrontendConfig = { + baseUrl: string + host: string + origin: string + port: number +} + +type ApiConfig = { + baseUrl: string + origin: string + readinessUrl: string +} + +function resolveFrontendConfig(): FrontendConfig { + const rawFrontendBaseUrl = process.env.TASKDECK_E2E_FRONTEND_BASE_URL + if (rawFrontendBaseUrl && rawFrontendBaseUrl.trim().length > 0) { + return resolveFrontendConfigFromBaseUrl(rawFrontendBaseUrl) + } + + const host = parseFrontendHost( + process.env.TASKDECK_E2E_FRONTEND_HOST ?? defaultFrontendHost, + 'TASKDECK_E2E_FRONTEND_HOST', + ) + const explicitFrontendPort = process.env.TASKDECK_E2E_FRONTEND_PORT + const resolvedFrontendPort = process.env.TASKDECK_E2E_RESOLVED_FRONTEND_PORT + + const port = explicitFrontendPort + ? parsePort(explicitFrontendPort, defaultFrontendPort, 'TASKDECK_E2E_FRONTEND_PORT') + : resolvedFrontendPort + ? parsePort( + resolvedFrontendPort, + defaultFrontendPort, + 'TASKDECK_E2E_RESOLVED_FRONTEND_PORT', + ) + : resolveDefaultFrontendPort(host, { + allowExistingFrontendReuse: reuseExistingServer, + }) + + if (!explicitFrontendPort && !resolvedFrontendPort) { + // Keep runner/worker baseURL aligned by reusing the first resolved port value. + process.env.TASKDECK_E2E_RESOLVED_FRONTEND_PORT = String(port) + } + + const origin = buildHttpOrigin(host, port) + + return { + baseUrl: origin, + host, + origin, + port, + } +} + +function resolveFrontendConfigFromBaseUrl(rawFrontendBaseUrl: string): FrontendConfig { + const parsedFrontendBaseUrl = parseFrontendBaseUrl(rawFrontendBaseUrl) + if (parsedFrontendBaseUrl.port.length === 0) { + throw new Error( + `[e2e config] TASKDECK_E2E_FRONTEND_BASE_URL must include an explicit port (example: "http://localhost:${defaultFrontendPort}"). Received "${rawFrontendBaseUrl}".`, + ) + } + + if (normalizePath(parsedFrontendBaseUrl.pathname).length > 0) { + throw new Error( + `[e2e config] TASKDECK_E2E_FRONTEND_BASE_URL cannot include a path segment. Use an origin only (example: "http://localhost:${defaultFrontendPort}"). Received "${rawFrontendBaseUrl}".`, + ) + } + + if (parsedFrontendBaseUrl.search.length > 0 || parsedFrontendBaseUrl.hash.length > 0) { + throw new Error( + `[e2e config] TASKDECK_E2E_FRONTEND_BASE_URL cannot include query or hash fragments. Received "${rawFrontendBaseUrl}".`, + ) + } + + const port = parsePort( + parsedFrontendBaseUrl.port, + defaultFrontendPort, + 'TASKDECK_E2E_FRONTEND_BASE_URL', + ) + + return { + baseUrl: parsedFrontendBaseUrl.origin, + host: parseFrontendHost(parsedFrontendBaseUrl.hostname, 'TASKDECK_E2E_FRONTEND_BASE_URL'), + origin: parsedFrontendBaseUrl.origin, + port, + } +} + +function parseFrontendBaseUrl(rawFrontendBaseUrl: string): URL { + try { + const parsedFrontendBaseUrl = new URL(rawFrontendBaseUrl) + if (parsedFrontendBaseUrl.protocol !== 'http:') { + throw new Error('Only http:// is supported.') + } + + return parsedFrontendBaseUrl + } catch (error) { + const reason = error instanceof Error ? error.message : 'Invalid URL format.' + throw new Error( + `[e2e config] TASKDECK_E2E_FRONTEND_BASE_URL must be an absolute http URL (example: "http://localhost:${defaultFrontendPort}"). Received "${rawFrontendBaseUrl}". ${reason}`, + { cause: error }, + ) + } +} + +function parsePort(rawPort: string | undefined, fallbackPort: number, source: string): number { + if (!rawPort) { + return fallbackPort + } + + const normalizedPort = rawPort.trim() + if (!/^\d+$/.test(normalizedPort)) { + throw new Error(`[e2e config] ${source} must be an integer between 1 and 65535. Received "${rawPort}".`) + } + + const parsedPort = Number.parseInt(normalizedPort, 10) + if (parsedPort < 1 || parsedPort > 65535) { + throw new Error(`[e2e config] ${source} must be between 1 and 65535. Received "${rawPort}".`) + } + + return parsedPort +} + +function resolveApiConfig(rawApiBaseUrl: string): ApiConfig { + const parsedApiBaseUrl = parseApiBaseUrl(rawApiBaseUrl) + const apiPath = normalizePath(parsedApiBaseUrl.pathname) + if (apiPath.length === 0) { + throw new Error( + `[e2e config] TASKDECK_E2E_API_BASE_URL must include an API path (example: "${defaultApiBaseUrl}"). Received "${rawApiBaseUrl}".`, + ) + } + + const normalizedBaseUrl = `${parsedApiBaseUrl.origin}${apiPath}` + return { + baseUrl: normalizedBaseUrl, + origin: parsedApiBaseUrl.origin, + readinessUrl: `${normalizedBaseUrl}/boards`, + } +} + +function parseApiBaseUrl(rawApiBaseUrl: string): URL { + try { + const parsedApiBaseUrl = new URL(rawApiBaseUrl) + if (parsedApiBaseUrl.protocol !== 'http:') { + throw new Error('Only http:// is supported.') + } + + if (parsedApiBaseUrl.port.length === 0) { + throw new Error('An explicit port is required.') + } + + if (parsedApiBaseUrl.search.length > 0 || parsedApiBaseUrl.hash.length > 0) { + throw new Error('Query and hash fragments are not supported.') + } + + return parsedApiBaseUrl + } catch (error) { + const reason = error instanceof Error ? error.message : 'Invalid URL format.' + throw new Error( + `[e2e config] TASKDECK_E2E_API_BASE_URL must be an absolute http URL with explicit port (example: "${defaultApiBaseUrl}"). Received "${rawApiBaseUrl}". ${reason}`, + { cause: error }, + ) + } +} + +function normalizePath(pathname: string): string { + if (!pathname || pathname === '/') { + return '' + } + + return pathname.replace(/\/+$/, '') +} + +function resolveBackendCorsOrigins(frontendOrigin: string, rawOrigins: string | undefined): string[] { + return dedupeOrigins([frontendOrigin, 'http://localhost:5174', ...parseOriginList(rawOrigins)]) +} + +function parseOriginList(rawOrigins: string | undefined): string[] { + if (!rawOrigins) { + return [] + } + + return dedupeOrigins( + rawOrigins + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0), + ) +} + +function dedupeOrigins(origins: string[]): string[] { + return [...new Set(origins)] +} + +/** + * Resolve the worker count for the E2E runner. + * + * Priority: + * 1. `TASKDECK_E2E_WORKERS` env var (integer >= 1) when set. + * 2. `fallbackDefault` for every other run (both CI and local), so the + * fully-parallel contention budget is respected in all environments. + */ +function resolveWorkers(rawOverride: string | undefined, fallbackDefault: number): number { + if (rawOverride !== undefined) { + const trimmed = rawOverride.trim() + if (!/^\d+$/.test(trimmed)) { + throw new Error( + `[e2e config] TASKDECK_E2E_WORKERS must be a positive integer. Received "${rawOverride}".`, + ) + } + + const parsed = Number.parseInt(trimmed, 10) + if (parsed < 1) { + throw new Error( + `[e2e config] TASKDECK_E2E_WORKERS must be >= 1. Received "${rawOverride}".`, + ) + } + return parsed + } + + return fallbackDefault +} diff --git a/frontend/taskdeck-web/playwright.demo-llm.ts b/frontend/taskdeck-web/playwright.demo-llm.ts index f5aa5b3de..c489c65fe 100644 --- a/frontend/taskdeck-web/playwright.demo-llm.ts +++ b/frontend/taskdeck-web/playwright.demo-llm.ts @@ -19,60 +19,60 @@ export function resolveDemoBackendLlmEnv(env: NodeJS.ProcessEnv): Record = { - Llm__EnableLiveProviders: 'true', - Llm__AllowLiveProvidersInDevelopment: 'true', - Llm__Provider: provider, - } - - if (provider === 'Gemini') { - const apiKey = firstNonEmpty(env.Llm__Gemini__ApiKey, env.TASKDECK_DEMO_GEMINI_API_KEY, env.GEMINI_API_KEY) - if (!apiKey) { - return {} - } - - liveEnv.Llm__Gemini__ApiKey = apiKey - - const model = firstNonEmpty(env.TASKDECK_DEMO_GEMINI_MODEL, env.Llm__Gemini__Model) - if (model) { - liveEnv.Llm__Gemini__Model = model - } - - return liveEnv - } - - const apiKey = firstNonEmpty(env.Llm__OpenAi__ApiKey, env.TASKDECK_DEMO_OPENAI_API_KEY, env.OPENAI_API_KEY) - if (!apiKey) { - return {} - } - - liveEnv.Llm__OpenAi__ApiKey = apiKey - - const model = firstNonEmpty(env.TASKDECK_DEMO_OPENAI_MODEL, env.Llm__OpenAi__Model) - if (model) { - liveEnv.Llm__OpenAi__Model = model - } - - return liveEnv -} - + + const provider = resolveDemoProvider(env) + if (!provider) { + return {} + } + + const liveEnv: Record = { + Llm__EnableLiveProviders: 'true', + Llm__AllowLiveProvidersInDevelopment: 'true', + Llm__Provider: provider, + } + + if (provider === 'Gemini') { + const apiKey = firstNonEmpty(env.Llm__Gemini__ApiKey, env.TASKDECK_DEMO_GEMINI_API_KEY, env.GEMINI_API_KEY) + if (!apiKey) { + return {} + } + + liveEnv.Llm__Gemini__ApiKey = apiKey + + const model = firstNonEmpty(env.TASKDECK_DEMO_GEMINI_MODEL, env.Llm__Gemini__Model) + if (model) { + liveEnv.Llm__Gemini__Model = model + } + + return liveEnv + } + + const apiKey = firstNonEmpty(env.Llm__OpenAi__ApiKey, env.TASKDECK_DEMO_OPENAI_API_KEY, env.OPENAI_API_KEY) + if (!apiKey) { + return {} + } + + liveEnv.Llm__OpenAi__ApiKey = apiKey + + const model = firstNonEmpty(env.TASKDECK_DEMO_OPENAI_MODEL, env.Llm__OpenAi__Model) + if (model) { + liveEnv.Llm__OpenAi__Model = model + } + + return liveEnv +} + function shouldEnableLiveDemoLlm(env: NodeJS.ProcessEnv): boolean { const isDemoRun = parseTrueishEnv(env.TASKDECK_RUN_DEMO) || parseTrueishEnv(env.TASKDECK_DEMO_DIRECTOR) const isLiveLlmTestRun = parseTrueishEnv(env.TASKDECK_RUN_LIVE_LLM_TESTS) if (!isDemoRun && !isLiveLlmTestRun) { return false } - - if (parseTrueishEnv(env.TASKDECK_DEMO_SKIP_LLM)) { - return false - } - + + if (parseTrueishEnv(env.TASKDECK_DEMO_SKIP_LLM)) { + return false + } + if (parseTrueishEnv(env.TASKDECK_DEMO_DISABLE_LIVE_LLM)) { return false } @@ -122,35 +122,35 @@ function hasGeminiApiKey(env: NodeJS.ProcessEnv): boolean { function hasOpenAiApiKey(env: NodeJS.ProcessEnv): boolean { return firstNonEmpty(env.Llm__OpenAi__ApiKey, env.TASKDECK_DEMO_OPENAI_API_KEY, env.OPENAI_API_KEY) !== null } - -function normalizeProvider(value: string | undefined): DemoProvider | 'Mock' | null { - const normalized = value?.trim().toLowerCase() - if (!normalized) { - return null - } - - if (normalized === 'gemini') { - return 'Gemini' - } - - if (normalized === 'openai') { - return 'OpenAI' - } - - if (normalized === 'mock') { - return 'Mock' - } - - return null -} - -function firstNonEmpty(...values: Array): string | null { - for (const value of values) { - if (typeof value === 'string' && value.trim().length > 0) { - return value.trim() - } - } - - return null -} - + +function normalizeProvider(value: string | undefined): DemoProvider | 'Mock' | null { + const normalized = value?.trim().toLowerCase() + if (!normalized) { + return null + } + + if (normalized === 'gemini') { + return 'Gemini' + } + + if (normalized === 'openai') { + return 'OpenAI' + } + + if (normalized === 'mock') { + return 'Mock' + } + + return null +} + +function firstNonEmpty(...values: Array): string | null { + for (const value of values) { + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim() + } + } + + return null +} + diff --git a/frontend/taskdeck-web/scripts/demo-director.mjs b/frontend/taskdeck-web/scripts/demo-director.mjs index 23d164f9c..735a0bb25 100644 --- a/frontend/taskdeck-web/scripts/demo-director.mjs +++ b/frontend/taskdeck-web/scripts/demo-director.mjs @@ -1,19 +1,19 @@ -#!/usr/bin/env node -/** - * demo-director.mjs - * - * One-command demo runner that orchestrates: - * 1) seed - * 2) scenario - * 3) optional autopilot - * 4) guided Playwright clickthrough - * 5) artifact collection - */ - -import { spawnSync } from 'node:child_process' -import fs from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' +#!/usr/bin/env node +/** + * demo-director.mjs + * + * One-command demo runner that orchestrates: + * 1) seed + * 2) scenario + * 3) optional autopilot + * 4) guided Playwright clickthrough + * 5) artifact collection + */ + +import { spawnSync } from 'node:child_process' +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { applyDemoDirectorApiBaseUrl, buildDemoDirectorPortConflictHint, @@ -27,158 +27,158 @@ import { import { parseDemoDirectorArgs } from './demo-director-args.mjs' import { resolveScenarioSelectedBoardName } from './demo-scenario-defaults.mjs' import { assertSafeLocalApiTarget, parseTrueishEnv } from './demo-shared.mjs' - -const PLAYWRIGHT_SPAWN_MAX_BUFFER_BYTES = 50 * 1024 * 1024 - -function nowStamp() { - const date = new Date() - const pad = (value) => String(value).padStart(2, '0') - const padMs = (value) => String(value).padStart(3, '0') - const entropy = Math.random().toString(36).slice(2, 6) - return ( - `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-` + - `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}${padMs(date.getMilliseconds())}-${entropy}` - ) -} - + +const PLAYWRIGHT_SPAWN_MAX_BUFFER_BYTES = 50 * 1024 * 1024 + +function nowStamp() { + const date = new Date() + const pad = (value) => String(value).padStart(2, '0') + const padMs = (value) => String(value).padStart(3, '0') + const entropy = Math.random().toString(36).slice(2, 6) + return ( + `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-` + + `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}${padMs(date.getMilliseconds())}-${entropy}` + ) +} + async function walk(dirPath) { - const output = [] - const entries = await fs.readdir(dirPath, { withFileTypes: true }) - for (const entry of entries) { - const absolute = path.join(dirPath, entry.name) - if (entry.isDirectory()) { - output.push(...(await walk(absolute))) - } else { - output.push(absolute) - } - } - return output -} - -async function copyIfExists(sourcePath, destinationPath) { - try { - await fs.mkdir(path.dirname(destinationPath), { recursive: true }) - await fs.copyFile(sourcePath, destinationPath) - return true - } catch { - return false - } -} - + const output = [] + const entries = await fs.readdir(dirPath, { withFileTypes: true }) + for (const entry of entries) { + const absolute = path.join(dirPath, entry.name) + if (entry.isDirectory()) { + output.push(...(await walk(absolute))) + } else { + output.push(absolute) + } + } + return output +} + +async function copyIfExists(sourcePath, destinationPath) { + try { + await fs.mkdir(path.dirname(destinationPath), { recursive: true }) + await fs.copyFile(sourcePath, destinationPath) + return true + } catch { + return false + } +} + async function readNdjson(filePath) { - try { - const raw = await fs.readFile(filePath, 'utf8') - const rows = [] - for (const line of raw.split('\n')) { - const trimmed = line.trim() - if (!trimmed) continue - try { - rows.push(JSON.parse(trimmed)) - } catch { - // Ignore malformed trace rows. - } - } - return rows - } catch { - return [] - } -} - -function summarizeEvents(events) { - const byType = {} - for (const event of events) { - const type = event?.type || 'unknown' - byType[type] = (byType[type] || 0) + 1 - } - - const proposalEvents = events.filter((event) => event?.type === 'proposal.execute' || event?.type === 'queue.applied') - const proposalsByKey = new Map() - let fallbackKeyCounter = 0 - for (const event of proposalEvents) { - const proposalId = typeof event?.proposalId === 'string' && event.proposalId.trim() ? event.proposalId.trim() : null - const requestId = typeof event?.requestId === 'string' && event.requestId.trim() ? event.requestId.trim() : null - const boardId = typeof event?.boardId === 'string' && event.boardId.trim() ? event.boardId.trim() : null - const key = proposalId ? `proposal:${proposalId}` : requestId ? `request:${requestId}` : `event:${fallbackKeyCounter++}` - - const existing = proposalsByKey.get(key) - if (!existing) { - proposalsByKey.set(key, { - ts: event.ts, - type: event.type, - proposalId, - requestId, - boardId, - }) - continue - } - - // Prefer proposal.execute metadata when both queue.applied and proposal.execute exist for one proposal. - proposalsByKey.set(key, { - ts: existing.ts || event.ts, - type: existing.type === 'proposal.execute' ? existing.type : event.type, - proposalId: existing.proposalId || proposalId, - requestId: existing.requestId || requestId, - boardId: existing.boardId || boardId, - }) - } - const proposals = Array.from(proposalsByKey.values()) - - const captures = events - .filter((event) => event?.type === 'capture.create' || event?.type === 'capture.triage.outcome') - .map((event) => ({ - ts: event.ts, - type: event.type, - captureItemId: event.captureItemId || null, - boardId: event.boardId || null, - outcome: event.outcome || null, - proposalId: event.proposalId || null, - })) - - const autopilot = { - starts: events.filter((event) => event?.type === 'autopilot.start').length, - ends: events.filter((event) => event?.type === 'autopilot.end').length, - turnsOk: events.filter((event) => event?.type === 'autopilot.turn.ok').length, - turnsError: events.filter((event) => event?.type === 'autopilot.turn.error').length, - } - - return { byType, proposals, captures, autopilot } -} - -function listScenarioSteps(events, { limit = 80 } = {}) { - const rows = events - .filter((event) => - ['scenario.step.ok', 'scenario.step.skipped', 'scenario.step.error'].includes(String(event?.type || '')), - ) - .map((event) => ({ - ts: event.ts, - status: String(event.type || '').split('.').pop(), - stepIndex: typeof event.stepIndex === 'number' ? event.stepIndex : null, - stepLabel: event.stepLabel || null, - stepType: event.stepType || null, - reason: event.reason || null, - error: event.error || null, - })) - - rows.sort((left, right) => { - if (left.stepIndex != null && right.stepIndex != null) { - return left.stepIndex - right.stepIndex - } - return String(left.ts || '').localeCompare(String(right.ts || '')) - }) - - return rows.slice(0, limit) -} - + try { + const raw = await fs.readFile(filePath, 'utf8') + const rows = [] + for (const line of raw.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + try { + rows.push(JSON.parse(trimmed)) + } catch { + // Ignore malformed trace rows. + } + } + return rows + } catch { + return [] + } +} + +function summarizeEvents(events) { + const byType = {} + for (const event of events) { + const type = event?.type || 'unknown' + byType[type] = (byType[type] || 0) + 1 + } + + const proposalEvents = events.filter((event) => event?.type === 'proposal.execute' || event?.type === 'queue.applied') + const proposalsByKey = new Map() + let fallbackKeyCounter = 0 + for (const event of proposalEvents) { + const proposalId = typeof event?.proposalId === 'string' && event.proposalId.trim() ? event.proposalId.trim() : null + const requestId = typeof event?.requestId === 'string' && event.requestId.trim() ? event.requestId.trim() : null + const boardId = typeof event?.boardId === 'string' && event.boardId.trim() ? event.boardId.trim() : null + const key = proposalId ? `proposal:${proposalId}` : requestId ? `request:${requestId}` : `event:${fallbackKeyCounter++}` + + const existing = proposalsByKey.get(key) + if (!existing) { + proposalsByKey.set(key, { + ts: event.ts, + type: event.type, + proposalId, + requestId, + boardId, + }) + continue + } + + // Prefer proposal.execute metadata when both queue.applied and proposal.execute exist for one proposal. + proposalsByKey.set(key, { + ts: existing.ts || event.ts, + type: existing.type === 'proposal.execute' ? existing.type : event.type, + proposalId: existing.proposalId || proposalId, + requestId: existing.requestId || requestId, + boardId: existing.boardId || boardId, + }) + } + const proposals = Array.from(proposalsByKey.values()) + + const captures = events + .filter((event) => event?.type === 'capture.create' || event?.type === 'capture.triage.outcome') + .map((event) => ({ + ts: event.ts, + type: event.type, + captureItemId: event.captureItemId || null, + boardId: event.boardId || null, + outcome: event.outcome || null, + proposalId: event.proposalId || null, + })) + + const autopilot = { + starts: events.filter((event) => event?.type === 'autopilot.start').length, + ends: events.filter((event) => event?.type === 'autopilot.end').length, + turnsOk: events.filter((event) => event?.type === 'autopilot.turn.ok').length, + turnsError: events.filter((event) => event?.type === 'autopilot.turn.error').length, + } + + return { byType, proposals, captures, autopilot } +} + +function listScenarioSteps(events, { limit = 80 } = {}) { + const rows = events + .filter((event) => + ['scenario.step.ok', 'scenario.step.skipped', 'scenario.step.error'].includes(String(event?.type || '')), + ) + .map((event) => ({ + ts: event.ts, + status: String(event.type || '').split('.').pop(), + stepIndex: typeof event.stepIndex === 'number' ? event.stepIndex : null, + stepLabel: event.stepLabel || null, + stepType: event.stepType || null, + reason: event.reason || null, + error: event.error || null, + })) + + rows.sort((left, right) => { + if (left.stepIndex != null && right.stepIndex != null) { + return left.stepIndex - right.stepIndex + } + return String(left.ts || '').localeCompare(String(right.ts || '')) + }) + + return rows.slice(0, limit) +} + function listAutopilotTurns(events, { limit = 12 } = {}) { - const rows = events - .filter((event) => String(event?.type || '') === 'autopilot.turn.start') - .map((event) => ({ - ts: event.ts, - turn: event.turn, - decision: event.decision, - })) - - rows.sort((left, right) => (left.turn || 0) - (right.turn || 0)) + const rows = events + .filter((event) => String(event?.type || '') === 'autopilot.turn.start') + .map((event) => ({ + ts: event.ts, + turn: event.turn, + decision: event.decision, + })) + + rows.sort((left, right) => (left.turn || 0) - (right.turn || 0)) return rows.slice(0, limit) } @@ -186,24 +186,24 @@ async function writeJson(filePath, value) { await fs.mkdir(path.dirname(filePath), { recursive: true }) await fs.writeFile(filePath, JSON.stringify(value, null, 2), 'utf8') } - + async function main() { const args = parseDemoDirectorArgs(process.argv) - - const __dirname = path.dirname(fileURLToPath(import.meta.url)) - const webRoot = path.resolve(__dirname, '..') - const runtime = resolveDemoDirectorRuntime({ - webRoot, - e2eDb: args.e2eDb, - resetE2EDb: args.resetE2EDb, - freshServers: args.freshServers, - }) + + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const webRoot = path.resolve(__dirname, '..') + const runtime = resolveDemoDirectorRuntime({ + webRoot, + e2eDb: args.e2eDb, + resetE2EDb: args.resetE2EDb, + freshServers: args.freshServers, + }) const selectedApiBaseUrl = await resolveDemoDirectorApiBaseUrl({ requestedApiBaseUrl: resolveDemoDirectorRequestedApiBaseUrl({ e2eApiBaseUrl: process.env.TASKDECK_E2E_API_BASE_URL, apiBaseUrl: process.env.TASKDECK_API_BASE_URL, apiBase: process.env.TASKDECK_API_BASE, - forceFreshServers: runtime.forceFreshServers, + forceFreshServers: runtime.forceFreshServers, }), forceFreshServers: runtime.forceFreshServers, }) @@ -213,31 +213,31 @@ async function main() { }) const runId = args.runId || nowStamp() - const artifactDir = path.resolve(args.outputDir || path.join(webRoot, 'demo-artifacts', `run-${runId}`)) - const logsDir = path.join(artifactDir, 'logs') - const screenshotsDir = path.join(artifactDir, 'screenshots') - const playwrightOutDir = path.join(artifactDir, 'playwright') - - await resetDemoDirectorArtifacts(artifactDir) - if (runtime.shouldResetE2EDb) { - await resetDemoDirectorE2EDb(runtime.e2eDbPath) - } - await fs.mkdir(logsDir, { recursive: true }) - await fs.mkdir(screenshotsDir, { recursive: true }) - await fs.mkdir(playwrightOutDir, { recursive: true }) - - const tracePath = path.join(artifactDir, 'trace.ndjson') - const snapshotPath = path.join(artifactDir, 'snapshot.json') + const artifactDir = path.resolve(args.outputDir || path.join(webRoot, 'demo-artifacts', `run-${runId}`)) + const logsDir = path.join(artifactDir, 'logs') + const screenshotsDir = path.join(artifactDir, 'screenshots') + const playwrightOutDir = path.join(artifactDir, 'playwright') + + await resetDemoDirectorArtifacts(artifactDir) + if (runtime.shouldResetE2EDb) { + await resetDemoDirectorE2EDb(runtime.e2eDbPath) + } + await fs.mkdir(logsDir, { recursive: true }) + await fs.mkdir(screenshotsDir, { recursive: true }) + await fs.mkdir(playwrightOutDir, { recursive: true }) + + const tracePath = path.join(artifactDir, 'trace.ndjson') + const snapshotPath = path.join(artifactDir, 'snapshot.json') const selectedBoardName = await resolveScenarioSelectedBoardName({ scenarioIdOrPath: args.scenario, explicitBoardName: args.autopilotBoard, }) const env = applyDemoDirectorApiBaseUrl({ - ...process.env, - TASKDECK_RUN_DEMO: '1', - TASKDECK_DEMO_DIRECTOR: '1', - TASKDECK_DEMO_ARTIFACT_DIR: artifactDir, + ...process.env, + TASKDECK_RUN_DEMO: '1', + TASKDECK_DEMO_DIRECTOR: '1', + TASKDECK_DEMO_ARTIFACT_DIR: artifactDir, TASKDECK_DEMO_TRACE_PATH: tracePath, TASKDECK_DEMO_SNAPSHOT_PATH: snapshotPath, TASKDECK_DEMO_SCENARIO: args.scenario, @@ -248,70 +248,76 @@ async function main() { TASKDECK_DEMO_AUTOPILOT_BOARD: selectedBoardName, TASKDECK_DEMO_AUTOPILOT_LOOP: args.loop, TASKDECK_DEMO_AUTOPILOT_BRAIN: args.brain, - TASKDECK_DEMO_AUTOPILOT_INTERVAL_MS: String(args.intervalMs), - TASKDECK_DEMO_AUTOPILOT_RNG_SEED: args.rngSeed || '', - ...(runtime.e2eDbPath ? { TASKDECK_E2E_DB: runtime.e2eDbPath } : {}), - ...(runtime.forceFreshServers ? { TASKDECK_E2E_REUSE_EXISTING_SERVER: '0' } : {}), - }, selectedApiBaseUrl) - - const pwArgs = [ - 'playwright', - 'test', - 'tests/e2e/stakeholder-demo.spec.ts', - '--output', - playwrightOutDir, - '--reporter', - 'line', - ...args.playwrightArgs, - ] - if (args.project) { - pwArgs.splice(3, 0, '--project', args.project) - } - if (args.headed) { - pwArgs.push('--headed') - } - - const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'npx' - const commandArgs = process.platform === 'win32' ? ['/d', '/s', '/c', 'npx', ...pwArgs] : pwArgs - - const startedAt = new Date().toISOString() - const playwrightResult = spawnSync(command, commandArgs, { - cwd: webRoot, - env, - encoding: 'utf8', - maxBuffer: PLAYWRIGHT_SPAWN_MAX_BUFFER_BYTES, - }) - - if (playwrightResult.error) { - throw new Error(`Failed to launch Playwright via npx: ${String(playwrightResult.error.message || playwrightResult.error)}`) - } - - const playwrightLog = `${playwrightResult.stdout || ''}${playwrightResult.stderr || ''}` - await fs.writeFile(path.join(logsDir, 'playwright.log'), playwrightLog, 'utf8') - const portConflictHint = buildDemoDirectorPortConflictHint(playwrightLog) - if (portConflictHint) { - console.error(portConflictHint) - } - - const screenshots = [] - try { - const files = await walk(playwrightOutDir) - const pngs = files.filter((filePath) => filePath.toLowerCase().endsWith('.png')) - pngs.sort((left, right) => path.basename(left).localeCompare(path.basename(right))) - - for (const sourcePath of pngs) { - const fileName = path.basename(sourcePath) - const destinationPath = path.join(screenshotsDir, fileName) - if (await copyIfExists(sourcePath, destinationPath)) { - screenshots.push({ name: fileName, path: `screenshots/${fileName}` }) - } - } - } catch { - // Best-effort screenshot copy. - } - - const endedAt = new Date().toISOString() - const events = await readNdjson(tracePath) + TASKDECK_DEMO_AUTOPILOT_INTERVAL_MS: String(args.intervalMs), + TASKDECK_DEMO_AUTOPILOT_RNG_SEED: args.rngSeed || '', + ...(runtime.e2eDbPath ? { TASKDECK_E2E_DB: runtime.e2eDbPath } : {}), + ...(runtime.forceFreshServers ? { TASKDECK_E2E_REUSE_EXISTING_SERVER: '0' } : {}), + }, selectedApiBaseUrl) + + const pwArgs = [ + 'test', + 'tests/e2e/stakeholder-demo.spec.ts', + '--output', + playwrightOutDir, + '--reporter', + 'line', + ...args.playwrightArgs, + ] + if (args.project) { + pwArgs.splice(3, 0, '--project', args.project) + } + if (args.headed) { + pwArgs.push('--headed') + } + + const playwrightCliPath = path.join(webRoot, 'node_modules', '@playwright', 'test', 'cli.js') + try { + await fs.access(playwrightCliPath) + } catch { + throw new Error('Playwright CLI not found. Run npm install in frontend/taskdeck-web before starting the demo director.') + } + + const command = process.execPath + const commandArgs = [playwrightCliPath, ...pwArgs] + + const startedAt = new Date().toISOString() + const playwrightResult = spawnSync(command, commandArgs, { + cwd: webRoot, + env, + encoding: 'utf8', + maxBuffer: PLAYWRIGHT_SPAWN_MAX_BUFFER_BYTES, + }) + + if (playwrightResult.error) { + throw new Error(`Failed to launch Playwright: ${String(playwrightResult.error.message || playwrightResult.error)}`) + } + + const playwrightLog = `${playwrightResult.stdout || ''}${playwrightResult.stderr || ''}` + await fs.writeFile(path.join(logsDir, 'playwright.log'), playwrightLog, 'utf8') + const portConflictHint = buildDemoDirectorPortConflictHint(playwrightLog) + if (portConflictHint) { + console.error(portConflictHint) + } + + const screenshots = [] + try { + const files = await walk(playwrightOutDir) + const pngs = files.filter((filePath) => filePath.toLowerCase().endsWith('.png')) + pngs.sort((left, right) => path.basename(left).localeCompare(path.basename(right))) + + for (const sourcePath of pngs) { + const fileName = path.basename(sourcePath) + const destinationPath = path.join(screenshotsDir, fileName) + if (await copyIfExists(sourcePath, destinationPath)) { + screenshots.push({ name: fileName, path: `screenshots/${fileName}` }) + } + } + } catch { + // Best-effort screenshot copy. + } + + const endedAt = new Date().toISOString() + const events = await readNdjson(tracePath) const summary = summarizeEvents(events) const scenarioSteps = listScenarioSteps(events) const autopilotTurns = listAutopilotTurns(events) @@ -332,107 +338,107 @@ async function main() { }) await writeJson(path.join(artifactDir, 'run-summary.json'), runSummary) - - const lines = [] - lines.push(`# Taskdeck demo run: ${runId}`) - lines.push('') - lines.push(`- Scenario: **${args.scenario}**${args.skipLlm ? ' (LLM steps skipped)' : ''}`) - lines.push(`- Seed: ${args.skipSeed ? 'skipped' : 'enabled'}`) - lines.push( - `- Autopilot: ${ - args.turns > 0 - ? `enabled (${args.turns} turns, ${args.brain}/${args.loop}${args.rngSeed ? `, seed=${args.rngSeed}` : ''})` - : 'disabled' - }`, - ) - lines.push(`- Playwright: project=${args.project || '(default)'}${args.headed ? ', headed' : ''}`) - lines.push(`- Status: **${runSummary.status}** (exit=${playwrightExitCode}${playwrightSignal ? `, signal=${playwrightSignal}` : ''})`) - lines.push('') - lines.push('## Artifacts') - lines.push('') - lines.push('- Trace (NDJSON): `trace.ndjson`') - lines.push('- Snapshot: `snapshot.json`') - lines.push('- Logs: `logs/`') - lines.push('- Raw Playwright output: `playwright/`') - lines.push('') - lines.push('## Walkthrough screenshots') - lines.push('') - - if (screenshots.length === 0) { - lines.push('_No screenshots copied. Check `playwright/` for raw output._') - } else { - for (const screenshot of screenshots.sort((left, right) => left.name.localeCompare(right.name))) { - lines.push(`- [${screenshot.name}](${screenshot.path})`) - } - } - - lines.push('') - lines.push('## Key counters') - lines.push('') - lines.push(`- Events in trace: ${events.length}`) - lines.push(`- Proposals executed: ${summary.proposals.length}`) - lines.push(`- Capture items (create/outcome events): ${summary.captures.length}`) - lines.push(`- Autopilot turns OK / error: ${summary.autopilot.turnsOk} / ${summary.autopilot.turnsError}`) - lines.push('') - - if (scenarioSteps.length > 0) { - lines.push('## Scenario steps') - lines.push('') - for (const step of scenarioSteps) { - const status = step.status === 'ok' ? 'ok' : step.status === 'skipped' ? 'skipped' : 'error' - const label = step.stepLabel || `step ${step.stepIndex ?? '?'}` - const extra = step.reason ? ` - ${step.reason}` : step.error ? ` - ${step.error}` : '' - lines.push(`- [${status}] ${label}${extra}`) - } - lines.push('') - } - - if (autopilotTurns.length > 0) { - lines.push('## Autopilot sample turns') - lines.push('') - for (const turn of autopilotTurns) { - const decision = turn.decision || {} - if (decision.kind === 'instruction') { - lines.push(`- Turn ${turn.turn}: instruction - ${String(decision.instruction || '').slice(0, 160)}`) - } else if (decision.kind === 'capture') { - lines.push(`- Turn ${turn.turn}: capture - ${String(decision.text || '').slice(0, 160)}`) - } else { - lines.push(`- Turn ${turn.turn}: ${JSON.stringify(decision).slice(0, 160)}`) - } - } - lines.push('') - } - - if (summary.proposals.length > 0) { - lines.push('## Proposals executed') - lines.push('') - for (const proposal of summary.proposals.slice(0, 30)) { - const proposalToken = proposal.proposalId ? `proposal=${proposal.proposalId}` : '' - const requestToken = proposal.requestId ? `request=${proposal.requestId}` : '' - const boardToken = proposal.boardId ? `board=${proposal.boardId}` : '' - const meta = [proposalToken, requestToken, boardToken].filter(Boolean).join(' ') - lines.push(`- ${proposal.ts || ''} ${proposal.type}${meta ? ` - ${meta}` : ''}`) - } - lines.push('') - } - - lines.push('## Next steps') - lines.push('') - lines.push('- Use `snapshot.json` to verify that key surfaces have data.') - lines.push('- Use `trace.ndjson` to inspect scenario/autopilot behavior.') - lines.push('- For CI, use `--rng-seed ` and `--skip-llm` for deterministic runs.') - if (portConflictHint) { - lines.push(`- Port-conflict hint: ${portConflictHint}`) - } - lines.push('') - - await fs.writeFile(path.join(artifactDir, 'README.md'), lines.join('\n'), 'utf8') - - process.exitCode = typeof playwrightExitCode === 'number' ? playwrightExitCode : 1 - console.log(`\nDemo artifacts written to: ${artifactDir}`) - console.log(`Status: ${runSummary.status} (exit=${playwrightExitCode}${playwrightSignal ? `, signal=${playwrightSignal}` : ''})`) -} - + + const lines = [] + lines.push(`# Taskdeck demo run: ${runId}`) + lines.push('') + lines.push(`- Scenario: **${args.scenario}**${args.skipLlm ? ' (LLM steps skipped)' : ''}`) + lines.push(`- Seed: ${args.skipSeed ? 'skipped' : 'enabled'}`) + lines.push( + `- Autopilot: ${ + args.turns > 0 + ? `enabled (${args.turns} turns, ${args.brain}/${args.loop}${args.rngSeed ? `, seed=${args.rngSeed}` : ''})` + : 'disabled' + }`, + ) + lines.push(`- Playwright: project=${args.project || '(default)'}${args.headed ? ', headed' : ''}`) + lines.push(`- Status: **${runSummary.status}** (exit=${playwrightExitCode}${playwrightSignal ? `, signal=${playwrightSignal}` : ''})`) + lines.push('') + lines.push('## Artifacts') + lines.push('') + lines.push('- Trace (NDJSON): `trace.ndjson`') + lines.push('- Snapshot: `snapshot.json`') + lines.push('- Logs: `logs/`') + lines.push('- Raw Playwright output: `playwright/`') + lines.push('') + lines.push('## Walkthrough screenshots') + lines.push('') + + if (screenshots.length === 0) { + lines.push('_No screenshots copied. Check `playwright/` for raw output._') + } else { + for (const screenshot of screenshots.sort((left, right) => left.name.localeCompare(right.name))) { + lines.push(`- [${screenshot.name}](${screenshot.path})`) + } + } + + lines.push('') + lines.push('## Key counters') + lines.push('') + lines.push(`- Events in trace: ${events.length}`) + lines.push(`- Proposals executed: ${summary.proposals.length}`) + lines.push(`- Capture items (create/outcome events): ${summary.captures.length}`) + lines.push(`- Autopilot turns OK / error: ${summary.autopilot.turnsOk} / ${summary.autopilot.turnsError}`) + lines.push('') + + if (scenarioSteps.length > 0) { + lines.push('## Scenario steps') + lines.push('') + for (const step of scenarioSteps) { + const status = step.status === 'ok' ? 'ok' : step.status === 'skipped' ? 'skipped' : 'error' + const label = step.stepLabel || `step ${step.stepIndex ?? '?'}` + const extra = step.reason ? ` - ${step.reason}` : step.error ? ` - ${step.error}` : '' + lines.push(`- [${status}] ${label}${extra}`) + } + lines.push('') + } + + if (autopilotTurns.length > 0) { + lines.push('## Autopilot sample turns') + lines.push('') + for (const turn of autopilotTurns) { + const decision = turn.decision || {} + if (decision.kind === 'instruction') { + lines.push(`- Turn ${turn.turn}: instruction - ${String(decision.instruction || '').slice(0, 160)}`) + } else if (decision.kind === 'capture') { + lines.push(`- Turn ${turn.turn}: capture - ${String(decision.text || '').slice(0, 160)}`) + } else { + lines.push(`- Turn ${turn.turn}: ${JSON.stringify(decision).slice(0, 160)}`) + } + } + lines.push('') + } + + if (summary.proposals.length > 0) { + lines.push('## Proposals executed') + lines.push('') + for (const proposal of summary.proposals.slice(0, 30)) { + const proposalToken = proposal.proposalId ? `proposal=${proposal.proposalId}` : '' + const requestToken = proposal.requestId ? `request=${proposal.requestId}` : '' + const boardToken = proposal.boardId ? `board=${proposal.boardId}` : '' + const meta = [proposalToken, requestToken, boardToken].filter(Boolean).join(' ') + lines.push(`- ${proposal.ts || ''} ${proposal.type}${meta ? ` - ${meta}` : ''}`) + } + lines.push('') + } + + lines.push('## Next steps') + lines.push('') + lines.push('- Use `snapshot.json` to verify that key surfaces have data.') + lines.push('- Use `trace.ndjson` to inspect scenario/autopilot behavior.') + lines.push('- For CI, use `--rng-seed ` and `--skip-llm` for deterministic runs.') + if (portConflictHint) { + lines.push(`- Port-conflict hint: ${portConflictHint}`) + } + lines.push('') + + await fs.writeFile(path.join(artifactDir, 'README.md'), lines.join('\n'), 'utf8') + + process.exitCode = typeof playwrightExitCode === 'number' ? playwrightExitCode : 1 + console.log(`\nDemo artifacts written to: ${artifactDir}`) + console.log(`Status: ${runSummary.status} (exit=${playwrightExitCode}${playwrightSignal ? `, signal=${playwrightSignal}` : ''})`) +} + const isDirectEntry = process.argv[1] ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false if (isDirectEntry) { diff --git a/frontend/taskdeck-web/scripts/demo-run.mjs b/frontend/taskdeck-web/scripts/demo-run.mjs index 7a6ed7e97..4cf6a68df 100644 --- a/frontend/taskdeck-web/scripts/demo-run.mjs +++ b/frontend/taskdeck-web/scripts/demo-run.mjs @@ -18,69 +18,69 @@ export async function main(argv = process.argv) { const jsonScenarioIds = await listJsonScenarioIds() if (args.list) { - console.log('Available JSON scenarios:') - jsonScenarioIds.forEach((scenarioId) => console.log(`- ${scenarioId}`)) - - const jsOnlyScenarios = Object.keys(jsScenarios) - .filter((scenarioId) => !jsonScenarioIds.includes(scenarioId)) - .sort() - - if (jsOnlyScenarios.length > 0) { - console.log('\nLegacy JS scenarios:') - jsOnlyScenarios.forEach((scenarioId) => console.log(`- ${scenarioId}`)) - } + console.log('Available JSON scenarios:') + jsonScenarioIds.forEach((scenarioId) => console.log(`- ${scenarioId}`)) + + const jsOnlyScenarios = Object.keys(jsScenarios) + .filter((scenarioId) => !jsonScenarioIds.includes(scenarioId)) + .sort() + + if (jsOnlyScenarios.length > 0) { + console.log('\nLegacy JS scenarios:') + jsOnlyScenarios.forEach((scenarioId) => console.log(`- ${scenarioId}`)) + } console.log('\nUsage:') console.log(' npm run demo:run -- [--clean [--dry-run]] [--skip-llm] [--continue-on-error]') console.log(' npm run demo:run -- --list') process.exit(0) } - - const config = getDemoConfig() - + + const config = getDemoConfig() + const scenarioName = args.scenario || 'client-onboarding' - - const api = new TaskdeckApiClient({ apiBaseUrl: config.apiBaseUrl }) - const demoLogin = await ensureUser(api, config.demoUser) - const authed = api.withToken(demoLogin.token) - - if (args.clean) { - const result = await cleanupDemoBoards(authed, { prefix: 'DEMO:', dryRun: args.dryRun }) - console.log(args.dryRun ? 'Would archive demo boards:' : 'Archived demo boards:') - console.log(JSON.stringify(result, null, 2)) - if (args.dryRun) return - } - - const shouldRunJson = jsonScenarioIds.includes(scenarioName) || scenarioName.endsWith('.json') - if (shouldRunJson) { - const scenario = await loadJsonScenario(scenarioName) - const summary = await runJsonScenario({ - api: authed, - config, - scenario, - options: { - skipLlm: args.skipLlm, - continueOnError: args.continueOnError, - }, - }) - - console.log('\nScenario complete.') - if (summary) console.log(JSON.stringify(summary, null, 2)) - return - } - - const jsLoader = jsScenarios[scenarioName] - if (!jsLoader) { - console.error(`Unknown scenario: ${scenarioName}`) - console.error('Run with --list to see available scenarios.') - process.exit(1) - } - - const jsScenario = await jsLoader() - if (typeof jsScenario.run !== 'function') { - throw new Error(`Scenario module "${scenarioName}" must export async function run(ctx)`) - } - + + const api = new TaskdeckApiClient({ apiBaseUrl: config.apiBaseUrl }) + const demoLogin = await ensureUser(api, config.demoUser) + const authed = api.withToken(demoLogin.token) + + if (args.clean) { + const result = await cleanupDemoBoards(authed, { prefix: 'DEMO:', dryRun: args.dryRun }) + console.log(args.dryRun ? 'Would archive demo boards:' : 'Archived demo boards:') + console.log(JSON.stringify(result, null, 2)) + if (args.dryRun) return + } + + const shouldRunJson = jsonScenarioIds.includes(scenarioName) || scenarioName.endsWith('.json') + if (shouldRunJson) { + const scenario = await loadJsonScenario(scenarioName) + const summary = await runJsonScenario({ + api: authed, + config, + scenario, + options: { + skipLlm: args.skipLlm, + continueOnError: args.continueOnError, + }, + }) + + console.log('\nScenario complete.') + if (summary) console.log(JSON.stringify(summary, null, 2)) + return + } + + const jsLoader = jsScenarios[scenarioName] + if (!jsLoader) { + console.error(`Unknown scenario: ${scenarioName}`) + console.error('Run with --list to see available scenarios.') + process.exit(1) + } + + const jsScenario = await jsLoader() + if (typeof jsScenario.run !== 'function') { + throw new Error(`Scenario module "${scenarioName}" must export async function run(ctx)`) + } + const summary = await jsScenario.run({ api: authed, config }) console.log('\nScenario complete.') if (summary) console.log(JSON.stringify(summary, null, 2)) diff --git a/frontend/taskdeck-web/scripts/scenario-json-runner.mjs b/frontend/taskdeck-web/scripts/scenario-json-runner.mjs index 08b8414fe..e8e0818bc 100644 --- a/frontend/taskdeck-web/scripts/scenario-json-runner.mjs +++ b/frontend/taskdeck-web/scripts/scenario-json-runner.mjs @@ -1,882 +1,882 @@ -/** - * JSON scenario runner (schema-driven). - * - * Goals: - * - Let demos/tests reference scenario IDs (not executable JS modules). - * - Keep scenario data declarative and reviewable (easy to diff). - * - Provide a single engine that can evolve with new step types. - */ - -import fs from 'node:fs/promises' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -import { - applyStarterPack, - approveAndExecuteProposal, - cancelCaptureItem, - createCaptureItem, - enqueueAndApplyInstruction, - getCaptureItem, - getOpsRunLogs, - ignoreCaptureItem, - isoDaysFromNow, - runOpsCommand, - summarizeBoardForAgent, - traceEvent, - triageCaptureItem, - waitForOpsRun, - waitForCaptureOutcome, - waitForCaptureProposalId, -} from './demo-lib.mjs' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const SCENARIO_DIR = path.join(__dirname, 'scenarios-json') -const SUPPORTED_STEP_TYPES = new Set([ - 'createBoard', - 'applyStarterPack', - 'createCard', - 'updateCard', - 'moveCard', - 'addComment', - 'queueInstruction', - 'createCapture', - 'ignoreCapture', - 'cancelCapture', - 'triageCapture', - 'waitForCaptureProposal', - 'waitForCaptureOutcome', - 'executeProposal', - 'runOps', -]) -const DEFAULT_LLM_STEP_TYPES = new Set([ - 'queueInstruction', - 'triageCapture', - 'waitForCaptureProposal', - 'waitForCaptureOutcome', -]) -const OPS_RUN_STATUS_BY_CODE = { - 0: 'Queued', - 1: 'Running', - 2: 'Completed', - 3: 'Failed', - 4: 'TimedOut', - 5: 'Cancelled', -} -const OPS_RUN_FAILURE_STATUSES = new Set(['failed', 'timedout', 'cancelled']) -const OPS_LOG_PREVIEW_LIMIT = 20 -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - -function assert(condition, message) { - if (!condition) throw new Error(message) -} - -function stepRequiresLlm(step) { - return step?.requiresLlm === true || DEFAULT_LLM_STEP_TYPES.has(step?.type) -} - -function isNonEmptyString(value) { - return typeof value === 'string' && value.trim().length > 0 -} - -function isObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value) -} - -function parseOptionalPositiveInteger(rawValue, fieldName, fallbackValue) { - const value = rawValue === undefined || rawValue === null || rawValue === '' ? fallbackValue : Number(rawValue) - assert(Number.isFinite(value) && Number.isInteger(value) && value > 0, `${fieldName} must be a positive integer`) - return value -} - -function parseOptionalFiniteNumber(rawValue, fieldName) { - if (rawValue === undefined || rawValue === null) return null - - let valueToParse = rawValue - if (typeof rawValue === 'string') { - const trimmed = rawValue.trim() - if (trimmed === '') return null - valueToParse = trimmed - } - - const value = Number(valueToParse) - assert(Number.isFinite(value), `${fieldName} must be a finite number`) - return value -} - -function normalizeOpsRunStatus(status) { - if (typeof status === 'number') { - return OPS_RUN_STATUS_BY_CODE[status] || String(status) - } - - if (typeof status === 'string') { - const trimmed = status.trim() - if (!trimmed) return 'Unknown' - - const numericStatus = Number(trimmed) - if (Number.isInteger(numericStatus) && OPS_RUN_STATUS_BY_CODE[numericStatus]) { - return OPS_RUN_STATUS_BY_CODE[numericStatus] - } - - return trimmed - } - - return 'Unknown' -} - -function isOpsRunFailureStatus(status) { - return OPS_RUN_FAILURE_STATUSES.has(normalizeOpsRunStatus(status).toLowerCase()) -} - -function deepClone(value) { - return value === undefined ? undefined : JSON.parse(JSON.stringify(value)) -} - -function getByPath(obj, pathExpr) { - const parts = String(pathExpr) - .split('.') - .map((p) => p.trim()) - .filter(Boolean) - - let cur = obj - for (const part of parts) { - if (cur == null) return undefined - cur = cur[part] - } - - return cur -} - -function resolveTemplates(value, ctx, location = 'value') { - if (typeof value === 'string') { - return value.replace(/\$\{([^}]+)\}/g, (_match, expr) => { - const resolved = getByPath(ctx.refs, expr.trim()) - assert(resolved !== undefined, `Unresolved scenario template expression "${expr.trim()}" at ${location}`) - return String(resolved) - }) - } - - if (Array.isArray(value)) { - return value.map((entry, index) => resolveTemplates(entry, ctx, `${location}[${index}]`)) - } - - if (isObject(value)) { - const out = {} - for (const [key, entry] of Object.entries(value)) { - out[key] = resolveTemplates(entry, ctx, `${location}.${key}`) - } - return out - } - - return value -} - -function registerScenarioAlias(aliasRegistry, { namespace, alias, stepIndex, stepType, propertyName }) { - if (!isNonEmptyString(alias)) { - return - } - - const normalizedAlias = alias.trim() - const namespaceRegistry = aliasRegistry.get(namespace) || new Map() - const existing = namespaceRegistry.get(normalizedAlias) - if (existing) { - throw new Error( - `Step[${stepIndex}] (${stepType}): ${propertyName} "${normalizedAlias}" duplicates Step[${existing.stepIndex}] (${existing.stepType}) in ${namespace}`, - ) - } - namespaceRegistry.set(normalizedAlias, { - stepIndex, - stepType, - propertyName, - }) - aliasRegistry.set(namespace, namespaceRegistry) -} - -function collectScenarioAliases(scenario) { - const aliasRegistry = new Map() - - for (const [index, step] of scenario.steps.entries()) { - switch (step.type) { - case 'createBoard': - registerScenarioAlias(aliasRegistry, { - namespace: 'boards', - alias: step.alias, - stepIndex: index, - stepType: step.type, - propertyName: 'alias', - }) - break - case 'createCard': - case 'updateCard': - case 'moveCard': - registerScenarioAlias(aliasRegistry, { - namespace: 'cards', - alias: step.alias, - stepIndex: index, - stepType: step.type, - propertyName: 'alias', - }) - break - case 'createCapture': - case 'triageCapture': - case 'waitForCaptureOutcome': - registerScenarioAlias(aliasRegistry, { - namespace: 'captures', - alias: step.alias, - stepIndex: index, - stepType: step.type, - propertyName: 'alias', - }) - break - case 'queueInstruction': - registerScenarioAlias(aliasRegistry, { - namespace: 'queueRequests', - alias: step.requestAlias, - stepIndex: index, - stepType: step.type, - propertyName: 'requestAlias', - }) - registerScenarioAlias(aliasRegistry, { - namespace: 'proposals', - alias: step.proposalAlias, - stepIndex: index, - stepType: step.type, - propertyName: 'proposalAlias', - }) - break - case 'waitForCaptureProposal': - registerScenarioAlias(aliasRegistry, { - namespace: 'proposals', - alias: step.proposalAlias, - stepIndex: index, - stepType: step.type, - propertyName: 'proposalAlias', - }) - break - case 'runOps': - registerScenarioAlias(aliasRegistry, { - namespace: 'opsRuns', - alias: step.alias, - stepIndex: index, - stepType: step.type, - propertyName: 'alias', - }) - break - default: - break - } - } -} - -export async function listJsonScenarioIds() { - let files - try { - files = await fs.readdir(SCENARIO_DIR) - } catch { - return [] - } - - return files - .filter((name) => name.endsWith('.json')) - .filter((name) => !name.startsWith('schema')) - .map((name) => path.basename(name, '.json')) - .sort() -} - -export async function loadJsonScenario(scenarioIdOrPath) { - const value = String(scenarioIdOrPath || '').trim() - assert(value, 'Scenario id/path is required') - - const requestedPath = value.endsWith('.json') ? value : `${value}.json` - const normalizedRequestedPath = path.normalize(requestedPath) - assert(!path.isAbsolute(normalizedRequestedPath), 'Absolute scenario paths are not allowed') - - const fullPath = path.resolve(SCENARIO_DIR, normalizedRequestedPath) - const relativeToScenarioDir = path.relative(SCENARIO_DIR, fullPath) - const escapesScenarioDir = - !relativeToScenarioDir || relativeToScenarioDir.startsWith('..') || path.isAbsolute(relativeToScenarioDir) - assert(!escapesScenarioDir, `Scenario path resolves outside scenarios-json: "${value}"`) - - const raw = await fs.readFile(fullPath, 'utf8') - return JSON.parse(raw) -} - -export function validateScenarioJson(scenario) { - assert(isObject(scenario), 'Scenario must be an object') - assert(scenario.version === 1, 'Scenario.version must be 1') - assert(typeof scenario.id === 'string' && scenario.id.length > 0, 'Scenario.id must be a non-empty string') - assert(typeof scenario.title === 'string' && scenario.title.length > 0, 'Scenario.title must be a non-empty string') - assert(Array.isArray(scenario.steps), 'Scenario.steps must be an array') - - for (const [index, step] of scenario.steps.entries()) { - assert(isObject(step), `Step[${index}] must be an object`) - assert(typeof step.type === 'string' && step.type.length > 0, `Step[${index}].type must be a string`) - assert(SUPPORTED_STEP_TYPES.has(step.type), `Step[${index}].type "${step.type}" is not supported`) - validateScenarioStep(index, step) - } - - collectScenarioAliases(scenario) - - return true -} - -function validateScenarioStep(index, step) { - const stepLabel = `Step[${index}] (${step.type})` - - switch (step.type) { - case 'createBoard': - assert(isNonEmptyString(step.name), `${stepLabel}: name is required`) - break - case 'applyStarterPack': - assert(isNonEmptyString(step.board), `${stepLabel}: board is required`) - assert(isNonEmptyString(step.starterPackId), `${stepLabel}: starterPackId is required`) - break - case 'createCard': - assert(isNonEmptyString(step.board), `${stepLabel}: board is required`) - assert(isNonEmptyString(step.column), `${stepLabel}: column is required`) - assert(isNonEmptyString(step.title), `${stepLabel}: title is required`) - break - case 'updateCard': - assert(isNonEmptyString(step.board), `${stepLabel}: board is required`) - assert(isNonEmptyString(step.card), `${stepLabel}: card is required`) - assert(isObject(step.patch), `${stepLabel}: patch must be an object`) - break - case 'moveCard': - assert(isNonEmptyString(step.board), `${stepLabel}: board is required`) - assert(isNonEmptyString(step.card), `${stepLabel}: card is required`) - assert(isNonEmptyString(step.toColumn), `${stepLabel}: toColumn is required`) - break - case 'addComment': - assert(isNonEmptyString(step.board), `${stepLabel}: board is required`) - assert(isNonEmptyString(step.card), `${stepLabel}: card is required`) - assert(isNonEmptyString(step.content), `${stepLabel}: content is required`) - break - case 'queueInstruction': - assert(isNonEmptyString(step.board), `${stepLabel}: board is required`) - assert(isNonEmptyString(step.instruction), `${stepLabel}: instruction is required`) - break - case 'createCapture': - if (Object.prototype.hasOwnProperty.call(step, 'board')) { - assert(isNonEmptyString(step.board), `${stepLabel}: board is present but empty`) - } - assert(isNonEmptyString(step.text), `${stepLabel}: text is required`) - break - case 'ignoreCapture': - case 'cancelCapture': - case 'triageCapture': - case 'waitForCaptureProposal': - case 'waitForCaptureOutcome': - assert(isNonEmptyString(step.capture), `${stepLabel}: capture is required`) - break - case 'executeProposal': - assert(isNonEmptyString(step.proposal), `${stepLabel}: proposal is required`) - break - case 'runOps': - assert(isNonEmptyString(step.templateName), `${stepLabel}: templateName is required`) - if (Object.prototype.hasOwnProperty.call(step, 'parameters')) { - assert( - step.parameters === null || isObject(step.parameters), - `${stepLabel}: parameters must be an object or null`, - ) - if (step.parameters !== null) { - const hasOnlyStringValues = Object.values(step.parameters).every((value) => typeof value === 'string') - assert(hasOnlyStringValues, `${stepLabel}: parameters values must all be strings`) - } - } - break - default: - break - } -} - -async function getBoardColumns(api, ctx, boardId, { force = false } = {}) { - if (!force && ctx.cache.columnsByBoardId.has(boardId)) { - return ctx.cache.columnsByBoardId.get(boardId) - } - - const columns = await api.get(`/boards/${boardId}/columns`) - const normalized = columns || [] - ctx.cache.columnsByBoardId.set(boardId, normalized) - return normalized -} - -async function getBoardLabels(api, ctx, boardId, { force = false } = {}) { - if (!force && ctx.cache.labelsByBoardId.has(boardId)) { - return ctx.cache.labelsByBoardId.get(boardId) - } - - const labels = await api.get(`/boards/${boardId}/labels`) - const normalized = labels || [] - ctx.cache.labelsByBoardId.set(boardId, normalized) - return normalized -} - -async function resolveBoardId(_api, ctx, boardRef) { - const ref = String(boardRef || '').trim() - assert(ref, 'board reference is required') - - const byAlias = ctx.refs.boards?.[ref] - if (byAlias?.id) return byAlias.id - return ref -} - -async function resolveCardId(_api, ctx, cardRef) { - const ref = String(cardRef || '').trim() - assert(ref, 'card reference is required') - - const byAlias = ctx.refs.cards?.[ref] - if (byAlias?.id) return byAlias.id - return ref -} - -async function resolveCaptureId(_api, ctx, captureRef) { - const ref = String(captureRef || '').trim() - assert(ref, 'capture reference is required') - - const byAlias = ctx.refs.captures?.[ref] - if (byAlias?.id) return byAlias.id - return ref -} - -async function resolveColumnIdByName(api, ctx, boardId, columnName) { - const columns = await getBoardColumns(api, ctx, boardId) - const matches = (columns || []).filter( - (column) => String(column?.name || '').toLowerCase() === String(columnName || '').toLowerCase(), - ) - - assert(matches.length > 0, `Column not found on board ${boardId}: "${columnName}"`) - assert( - matches.length === 1, - `Column name "${columnName}" is ambiguous on board ${boardId}: found ${matches.length} matches`, - ) - - const resolved = matches[0] - assert(resolved?.id, `Column not found on board ${boardId}: "${columnName}"`) - return resolved.id -} - -async function resolveLabelIdsByNames(api, ctx, boardId, labelNames = []) { - if (!labelNames || labelNames.length === 0) return [] - - const labels = await getBoardLabels(api, ctx, boardId) - const ids = [] - const missingLabelNames = [] - const duplicateLabelNames = [] - - for (const labelName of labelNames) { - const matches = (labels || []).filter( - (label) => String(label?.name || '').toLowerCase() === String(labelName || '').toLowerCase(), - ) - - if (matches.length === 1 && matches[0]?.id) { - ids.push(matches[0].id) - } else if (matches.length > 1) { - duplicateLabelNames.push(String(labelName || '')) - } else { - missingLabelNames.push(String(labelName || '')) - } - } - - assert( - missingLabelNames.length === 0, - `Labels not found on board ${boardId}: ${missingLabelNames.map((labelName) => `"${labelName}"`).join(', ')}`, - ) - assert( - duplicateLabelNames.length === 0, - `Label names are ambiguous on board ${boardId}: ${duplicateLabelNames.map((labelName) => `"${labelName}"`).join(', ')}`, - ) - - return ids -} - -export async function runJsonScenario({ api, config, scenario, options = {} }) { - validateScenarioJson(scenario) - - const opts = { - skipLlm: false, - continueOnError: false, - ...options, - } - - const ctx = { - api, - config, - options: opts, - warnings: [], - refs: { - boards: {}, - cards: {}, - captures: {}, - proposals: {}, - queueRequests: {}, - opsRuns: {}, - }, - cache: { - columnsByBoardId: new Map(), - labelsByBoardId: new Map(), - }, - results: { - steps: [], - }, - } - - for (const [index, rawStep] of scenario.steps.entries()) { - const step = resolveTemplates(deepClone(rawStep), ctx, `Step[${index}]`) - const label = step.label || `${index + 1}:${step.type}` - - await traceEvent({ - type: 'scenario.step.start', - scenarioId: scenario.id, - scenarioTitle: scenario.title, - stepIndex: index, - stepLabel: label, - stepType: step.type, - }) - - if (stepRequiresLlm(step) && opts.skipLlm) { - ctx.results.steps.push({ - step: label, - status: 'skipped', - reason: '--skip-llm', - }) - await traceEvent({ - type: 'scenario.step.skipped', - scenarioId: scenario.id, - stepIndex: index, - stepLabel: label, - stepType: step.type, - reason: '--skip-llm', - }) - continue - } - - try { - const result = await executeStep(api, ctx, step) - ctx.results.steps.push({ step: label, status: 'ok', result: result || null }) - await traceEvent({ - type: 'scenario.step.ok', - scenarioId: scenario.id, - stepIndex: index, - stepLabel: label, - stepType: step.type, - result: result || null, - }) - } catch (err) { - ctx.results.steps.push({ step: label, status: 'error', error: String(err?.message || err) }) - await traceEvent({ - type: 'scenario.step.error', - scenarioId: scenario.id, - stepIndex: index, - stepLabel: label, - stepType: step.type, - error: String(err?.message || err), - }) - if (!opts.continueOnError) throw err - } - } - - const boardsCreated = Object.values(ctx.refs.boards).map((board) => ({ id: board.id, name: board.name })) - const links = { - uiBoards: `${config.uiBaseUrl}/workspace/boards`, - uiInbox: `${config.uiBaseUrl}/workspace/inbox`, - uiProposals: `${config.uiBaseUrl}/workspace/automations/proposals`, - } - - if (boardsCreated.length === 1) { - links.uiBoard = `${config.uiBaseUrl}/workspace/boards/${boardsCreated[0].id}` - } - - let snapshot = null - if (boardsCreated.length >= 1) { - try { - const boardId = boardsCreated[0].id - const board = await api.get(`/boards/${boardId}`) - const columns = await api.get(`/boards/${boardId}/columns`) - const cards = await api.get(`/boards/${boardId}/cards`) - snapshot = summarizeBoardForAgent({ board, columns, cards }) - } catch (err) { - if (!opts.continueOnError) throw err - ctx.warnings.push(`Snapshot generation failed: ${String(err?.message || err)}`) - } - } - - return { - scenario: { id: scenario.id, title: scenario.title }, - boards: boardsCreated, - links, - warnings: ctx.warnings, - results: ctx.results, - snapshot, - } -} - -async function executeStep(api, ctx, step) { - switch (step.type) { - case 'createBoard': { - assert(typeof step.name === 'string' && step.name.length > 0, 'createBoard.name is required') - const board = await api.post('/boards', { - body: { - name: step.name, - description: step.description || null, - }, - }) - if (step.alias) ctx.refs.boards[step.alias] = board - return { boardId: board.id } - } - - case 'applyStarterPack': { - const boardId = await resolveBoardId(api, ctx, step.board) - assert( - typeof step.starterPackId === 'string' && step.starterPackId.length > 0, - 'applyStarterPack.starterPackId is required', - ) - - await applyStarterPack(api, { - boardId, - starterPackId: step.starterPackId, - dryRun: !!step.dryRun, - }) - - ctx.cache.columnsByBoardId.delete(boardId) - ctx.cache.labelsByBoardId.delete(boardId) - return { boardId, starterPackId: step.starterPackId } - } - - case 'createCard': { - assert(typeof step.board === 'string' && step.board.trim().length > 0, 'createCard.board is required') - assert(typeof step.column === 'string' && step.column.trim().length > 0, 'createCard.column is required') - assert(typeof step.title === 'string' && step.title.trim().length > 0, 'createCard.title is required') - const boardRef = step.board.trim() - const columnName = step.column.trim() - const boardId = await resolveBoardId(api, ctx, boardRef) - const columnId = await resolveColumnIdByName(api, ctx, boardId, columnName) - const labelIds = await resolveLabelIdsByNames(api, ctx, boardId, step.labels || []) - const dueInDays = parseOptionalFiniteNumber(step.dueInDays, 'createCard.dueInDays') - const dueDate = step.dueDate ? step.dueDate : dueInDays != null ? isoDaysFromNow(dueInDays) : null - const title = step.title.trim() - - const card = await api.post(`/boards/${boardId}/cards`, { - body: { - columnId, - title, - description: step.description || null, - dueDate, - labelIds, - }, - }) - - if (step.alias) ctx.refs.cards[step.alias] = card - return { cardId: card.id } - } - - case 'updateCard': { - const boardId = await resolveBoardId(api, ctx, step.board) - const cardId = await resolveCardId(api, ctx, step.card) - assert(isObject(step.patch), 'updateCard.patch must be an object') - - const updated = await api.patch(`/boards/${boardId}/cards/${cardId}`, { - body: step.patch, - }) - - if (step.alias) ctx.refs.cards[step.alias] = updated - return { cardId } - } - - case 'moveCard': { - const boardId = await resolveBoardId(api, ctx, step.board) - const cardId = await resolveCardId(api, ctx, step.card) - const columnId = await resolveColumnIdByName(api, ctx, boardId, step.toColumn) - - const moved = await api.post(`/boards/${boardId}/cards/${cardId}/move`, { - body: { columnId }, - }) - - if (step.alias) ctx.refs.cards[step.alias] = moved - return { cardId, columnId } - } - - case 'addComment': { - const boardId = await resolveBoardId(api, ctx, step.board) - const cardId = await resolveCardId(api, ctx, step.card) - assert(typeof step.content === 'string' && step.content.length > 0, 'addComment.content is required') - - const comment = await api.post(`/boards/${boardId}/cards/${cardId}/comments`, { - body: { content: step.content }, - }) - - return { commentId: comment.id } - } - - case 'queueInstruction': { - const boardId = await resolveBoardId(api, ctx, step.board) - assert( - typeof step.instruction === 'string' && step.instruction.length > 0, - 'queueInstruction.instruction is required', - ) - - const { request, proposal } = await enqueueAndApplyInstruction(api, { - boardId, - instruction: step.instruction, - timeoutMs: parseOptionalPositiveInteger(step.timeoutMs, 'queueInstruction.timeoutMs', 90_000), - }) - - if (step.requestAlias) ctx.refs.queueRequests[step.requestAlias] = request - if (step.proposalAlias) ctx.refs.proposals[step.proposalAlias] = proposal - return { requestId: request.id, proposalId: proposal.id } - } - - case 'createCapture': { - let boardId = null - if (Object.prototype.hasOwnProperty.call(step, 'board')) { - assert( - step.board !== null && step.board !== undefined && String(step.board).trim().length > 0, - 'createCapture.board is present but resolved to empty; check your refs', - ) - boardId = await resolveBoardId(api, ctx, step.board) - assert( - boardId !== null && boardId !== undefined && String(boardId).trim().length > 0, - 'createCapture.board resolved to an invalid boardId; check your refs', - ) - } - - assert(typeof step.text === 'string' && step.text.length > 0, 'createCapture.text is required') - - const captureItem = await createCaptureItem(api, { - boardId, - text: step.text, - source: step.source || 'Typed', - titleHint: step.titleHint || null, - externalRef: step.externalRef || null, - }) - - if (step.alias) ctx.refs.captures[step.alias] = captureItem - return { captureItemId: captureItem.id } - } - - case 'ignoreCapture': { - const captureItemId = await resolveCaptureId(api, ctx, step.capture) - await ignoreCaptureItem(api, captureItemId) - return { captureItemId } - } - - case 'cancelCapture': { - const captureItemId = await resolveCaptureId(api, ctx, step.capture) - await cancelCaptureItem(api, captureItemId) - return { captureItemId } - } - - case 'triageCapture': { - const captureItemId = await resolveCaptureId(api, ctx, step.capture) - await triageCaptureItem(api, captureItemId) - - const item = await getCaptureItem(api, captureItemId) - const captureAlias = String(step.capture || '').trim() - if (captureAlias) ctx.refs.captures[captureAlias] = item - if (step.alias) ctx.refs.captures[step.alias] = item - - return { - captureItemId, - status: item?.status, - } - } - - case 'waitForCaptureProposal': { - const captureAlias = String(step.capture || '').trim() - const captureItemId = await resolveCaptureId(api, ctx, captureAlias) - const timeoutMs = parseOptionalPositiveInteger(step.timeoutMs, 'waitForCaptureProposal.timeoutMs', 90_000) - const intervalMs = parseOptionalPositiveInteger(step.intervalMs, 'waitForCaptureProposal.intervalMs', 1200) - - const proposalId = await waitForCaptureProposalId(api, captureItemId, { timeoutMs, intervalMs }) - const item = await getCaptureItem(api, captureItemId) - if (captureAlias) ctx.refs.captures[captureAlias] = item - - ctx.refs.proposals[step.proposalAlias || `${captureAlias}:proposal`] = { id: proposalId } - return { captureItemId, proposalId } - } - - case 'waitForCaptureOutcome': { - const captureAlias = String(step.capture || '').trim() - const captureItemId = await resolveCaptureId(api, ctx, captureAlias) - - const outcome = await waitForCaptureOutcome(api, captureItemId, { - timeoutMs: parseOptionalPositiveInteger(step.timeoutMs, 'waitForCaptureOutcome.timeoutMs', 90_000), - intervalMs: parseOptionalPositiveInteger(step.intervalMs, 'waitForCaptureOutcome.intervalMs', 1200), - }) - - if (captureAlias) ctx.refs.captures[captureAlias] = outcome.item - if (step.alias) ctx.refs.captures[step.alias] = outcome.item - return { - captureItemId, - outcome: outcome.outcome, - status: outcome?.item?.status, - } - } - - case 'executeProposal': { - assert(typeof step.proposal === 'string' && step.proposal.length > 0, 'executeProposal.proposal is required') - const proposalRef = step.proposal.trim() - const proposal = ctx.refs.proposals?.[proposalRef] - const proposalId = proposal?.id || (UUID_RE.test(proposalRef) ? proposalRef : null) - - if (!proposalId) { - throw new Error( - `executeProposal: "${proposalRef}" did not resolve to a proposal ID. ` + - `This usually means a prior proposal-producing step (waitForCaptureProposal, queueInstruction) was skipped (e.g. --skip-llm). ` + - `Mark this step with "requiresLlm": true to skip it automatically.`, - ) - } - - await approveAndExecuteProposal(api, proposalId) - return { proposalId } - } - - case 'runOps': { - assert(isNonEmptyString(step.templateName), 'runOps.templateName is required') - const run = await runOpsCommand(api, { - templateName: step.templateName, - parameters: step.parameters || null, - }) - - const alias = isNonEmptyString(step.alias) ? step.alias.trim() : null - if (alias) ctx.refs.opsRuns[alias] = run - - if (step.wait === false) { - return { runId: run?.id || null, status: run?.status ?? null } - } - - const runId = run?.id - assert(isNonEmptyString(runId), 'runOps returned an invalid run id') - const done = await waitForOpsRun(api, runId, { - timeoutMs: parseOptionalPositiveInteger(step.timeoutMs, 'runOps.timeoutMs', 60_000), - intervalMs: parseOptionalPositiveInteger(step.intervalMs, 'runOps.intervalMs', 700), - }) - if (alias) ctx.refs.opsRuns[alias] = done - - const finalStatus = normalizeOpsRunStatus(done?.status) - if (isOpsRunFailureStatus(finalStatus)) { - const detail = isNonEmptyString(done?.errorMessage) ? `: ${done.errorMessage}` : '' - throw new Error(`Ops run ${runId} finished with non-success status ${finalStatus}${detail}`) - } - - const logs = step.includeLogs ? await getOpsRunLogs(api, runId) : null - const logsCount = Array.isArray(logs) ? logs.length : null - const logsPreview = Array.isArray(logs) ? logs.slice(0, OPS_LOG_PREVIEW_LIMIT) : null - if (alias && ctx.refs.opsRuns[alias]) { - ctx.refs.opsRuns[alias].logsCount = logsCount - ctx.refs.opsRuns[alias].logsPreview = logsPreview - } - - return { - runId, - status: finalStatus, - exitCode: done?.exitCode ?? null, - logsCount, - logsPreview, - } - } - - default: - throw new Error(`Unknown scenario step type: ${step.type}`) - } -} +/** + * JSON scenario runner (schema-driven). + * + * Goals: + * - Let demos/tests reference scenario IDs (not executable JS modules). + * - Keep scenario data declarative and reviewable (easy to diff). + * - Provide a single engine that can evolve with new step types. + */ + +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { + applyStarterPack, + approveAndExecuteProposal, + cancelCaptureItem, + createCaptureItem, + enqueueAndApplyInstruction, + getCaptureItem, + getOpsRunLogs, + ignoreCaptureItem, + isoDaysFromNow, + runOpsCommand, + summarizeBoardForAgent, + traceEvent, + triageCaptureItem, + waitForOpsRun, + waitForCaptureOutcome, + waitForCaptureProposalId, +} from './demo-lib.mjs' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const SCENARIO_DIR = path.join(__dirname, 'scenarios-json') +const SUPPORTED_STEP_TYPES = new Set([ + 'createBoard', + 'applyStarterPack', + 'createCard', + 'updateCard', + 'moveCard', + 'addComment', + 'queueInstruction', + 'createCapture', + 'ignoreCapture', + 'cancelCapture', + 'triageCapture', + 'waitForCaptureProposal', + 'waitForCaptureOutcome', + 'executeProposal', + 'runOps', +]) +const DEFAULT_LLM_STEP_TYPES = new Set([ + 'queueInstruction', + 'triageCapture', + 'waitForCaptureProposal', + 'waitForCaptureOutcome', +]) +const OPS_RUN_STATUS_BY_CODE = { + 0: 'Queued', + 1: 'Running', + 2: 'Completed', + 3: 'Failed', + 4: 'TimedOut', + 5: 'Cancelled', +} +const OPS_RUN_FAILURE_STATUSES = new Set(['failed', 'timedout', 'cancelled']) +const OPS_LOG_PREVIEW_LIMIT = 20 +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +function stepRequiresLlm(step) { + return step?.requiresLlm === true || DEFAULT_LLM_STEP_TYPES.has(step?.type) +} + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0 +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function parseOptionalPositiveInteger(rawValue, fieldName, fallbackValue) { + const value = rawValue === undefined || rawValue === null || rawValue === '' ? fallbackValue : Number(rawValue) + assert(Number.isFinite(value) && Number.isInteger(value) && value > 0, `${fieldName} must be a positive integer`) + return value +} + +function parseOptionalFiniteNumber(rawValue, fieldName) { + if (rawValue === undefined || rawValue === null) return null + + let valueToParse = rawValue + if (typeof rawValue === 'string') { + const trimmed = rawValue.trim() + if (trimmed === '') return null + valueToParse = trimmed + } + + const value = Number(valueToParse) + assert(Number.isFinite(value), `${fieldName} must be a finite number`) + return value +} + +function normalizeOpsRunStatus(status) { + if (typeof status === 'number') { + return OPS_RUN_STATUS_BY_CODE[status] || String(status) + } + + if (typeof status === 'string') { + const trimmed = status.trim() + if (!trimmed) return 'Unknown' + + const numericStatus = Number(trimmed) + if (Number.isInteger(numericStatus) && OPS_RUN_STATUS_BY_CODE[numericStatus]) { + return OPS_RUN_STATUS_BY_CODE[numericStatus] + } + + return trimmed + } + + return 'Unknown' +} + +function isOpsRunFailureStatus(status) { + return OPS_RUN_FAILURE_STATUSES.has(normalizeOpsRunStatus(status).toLowerCase()) +} + +function deepClone(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)) +} + +function getByPath(obj, pathExpr) { + const parts = String(pathExpr) + .split('.') + .map((p) => p.trim()) + .filter(Boolean) + + let cur = obj + for (const part of parts) { + if (cur == null) return undefined + cur = cur[part] + } + + return cur +} + +function resolveTemplates(value, ctx, location = 'value') { + if (typeof value === 'string') { + return value.replace(/\$\{([^}]+)\}/g, (_match, expr) => { + const resolved = getByPath(ctx.refs, expr.trim()) + assert(resolved !== undefined, `Unresolved scenario template expression "${expr.trim()}" at ${location}`) + return String(resolved) + }) + } + + if (Array.isArray(value)) { + return value.map((entry, index) => resolveTemplates(entry, ctx, `${location}[${index}]`)) + } + + if (isObject(value)) { + const out = {} + for (const [key, entry] of Object.entries(value)) { + out[key] = resolveTemplates(entry, ctx, `${location}.${key}`) + } + return out + } + + return value +} + +function registerScenarioAlias(aliasRegistry, { namespace, alias, stepIndex, stepType, propertyName }) { + if (!isNonEmptyString(alias)) { + return + } + + const normalizedAlias = alias.trim() + const namespaceRegistry = aliasRegistry.get(namespace) || new Map() + const existing = namespaceRegistry.get(normalizedAlias) + if (existing) { + throw new Error( + `Step[${stepIndex}] (${stepType}): ${propertyName} "${normalizedAlias}" duplicates Step[${existing.stepIndex}] (${existing.stepType}) in ${namespace}`, + ) + } + namespaceRegistry.set(normalizedAlias, { + stepIndex, + stepType, + propertyName, + }) + aliasRegistry.set(namespace, namespaceRegistry) +} + +function collectScenarioAliases(scenario) { + const aliasRegistry = new Map() + + for (const [index, step] of scenario.steps.entries()) { + switch (step.type) { + case 'createBoard': + registerScenarioAlias(aliasRegistry, { + namespace: 'boards', + alias: step.alias, + stepIndex: index, + stepType: step.type, + propertyName: 'alias', + }) + break + case 'createCard': + case 'updateCard': + case 'moveCard': + registerScenarioAlias(aliasRegistry, { + namespace: 'cards', + alias: step.alias, + stepIndex: index, + stepType: step.type, + propertyName: 'alias', + }) + break + case 'createCapture': + case 'triageCapture': + case 'waitForCaptureOutcome': + registerScenarioAlias(aliasRegistry, { + namespace: 'captures', + alias: step.alias, + stepIndex: index, + stepType: step.type, + propertyName: 'alias', + }) + break + case 'queueInstruction': + registerScenarioAlias(aliasRegistry, { + namespace: 'queueRequests', + alias: step.requestAlias, + stepIndex: index, + stepType: step.type, + propertyName: 'requestAlias', + }) + registerScenarioAlias(aliasRegistry, { + namespace: 'proposals', + alias: step.proposalAlias, + stepIndex: index, + stepType: step.type, + propertyName: 'proposalAlias', + }) + break + case 'waitForCaptureProposal': + registerScenarioAlias(aliasRegistry, { + namespace: 'proposals', + alias: step.proposalAlias, + stepIndex: index, + stepType: step.type, + propertyName: 'proposalAlias', + }) + break + case 'runOps': + registerScenarioAlias(aliasRegistry, { + namespace: 'opsRuns', + alias: step.alias, + stepIndex: index, + stepType: step.type, + propertyName: 'alias', + }) + break + default: + break + } + } +} + +export async function listJsonScenarioIds() { + let files + try { + files = await fs.readdir(SCENARIO_DIR) + } catch { + return [] + } + + return files + .filter((name) => name.endsWith('.json')) + .filter((name) => !name.startsWith('schema')) + .map((name) => path.basename(name, '.json')) + .sort() +} + +export async function loadJsonScenario(scenarioIdOrPath) { + const value = String(scenarioIdOrPath || '').trim() + assert(value, 'Scenario id/path is required') + + const requestedPath = value.endsWith('.json') ? value : `${value}.json` + const normalizedRequestedPath = path.normalize(requestedPath) + assert(!path.isAbsolute(normalizedRequestedPath), 'Absolute scenario paths are not allowed') + + const fullPath = path.resolve(SCENARIO_DIR, normalizedRequestedPath) + const relativeToScenarioDir = path.relative(SCENARIO_DIR, fullPath) + const escapesScenarioDir = + !relativeToScenarioDir || relativeToScenarioDir.startsWith('..') || path.isAbsolute(relativeToScenarioDir) + assert(!escapesScenarioDir, `Scenario path resolves outside scenarios-json: "${value}"`) + + const raw = await fs.readFile(fullPath, 'utf8') + return JSON.parse(raw) +} + +export function validateScenarioJson(scenario) { + assert(isObject(scenario), 'Scenario must be an object') + assert(scenario.version === 1, 'Scenario.version must be 1') + assert(typeof scenario.id === 'string' && scenario.id.length > 0, 'Scenario.id must be a non-empty string') + assert(typeof scenario.title === 'string' && scenario.title.length > 0, 'Scenario.title must be a non-empty string') + assert(Array.isArray(scenario.steps), 'Scenario.steps must be an array') + + for (const [index, step] of scenario.steps.entries()) { + assert(isObject(step), `Step[${index}] must be an object`) + assert(typeof step.type === 'string' && step.type.length > 0, `Step[${index}].type must be a string`) + assert(SUPPORTED_STEP_TYPES.has(step.type), `Step[${index}].type "${step.type}" is not supported`) + validateScenarioStep(index, step) + } + + collectScenarioAliases(scenario) + + return true +} + +function validateScenarioStep(index, step) { + const stepLabel = `Step[${index}] (${step.type})` + + switch (step.type) { + case 'createBoard': + assert(isNonEmptyString(step.name), `${stepLabel}: name is required`) + break + case 'applyStarterPack': + assert(isNonEmptyString(step.board), `${stepLabel}: board is required`) + assert(isNonEmptyString(step.starterPackId), `${stepLabel}: starterPackId is required`) + break + case 'createCard': + assert(isNonEmptyString(step.board), `${stepLabel}: board is required`) + assert(isNonEmptyString(step.column), `${stepLabel}: column is required`) + assert(isNonEmptyString(step.title), `${stepLabel}: title is required`) + break + case 'updateCard': + assert(isNonEmptyString(step.board), `${stepLabel}: board is required`) + assert(isNonEmptyString(step.card), `${stepLabel}: card is required`) + assert(isObject(step.patch), `${stepLabel}: patch must be an object`) + break + case 'moveCard': + assert(isNonEmptyString(step.board), `${stepLabel}: board is required`) + assert(isNonEmptyString(step.card), `${stepLabel}: card is required`) + assert(isNonEmptyString(step.toColumn), `${stepLabel}: toColumn is required`) + break + case 'addComment': + assert(isNonEmptyString(step.board), `${stepLabel}: board is required`) + assert(isNonEmptyString(step.card), `${stepLabel}: card is required`) + assert(isNonEmptyString(step.content), `${stepLabel}: content is required`) + break + case 'queueInstruction': + assert(isNonEmptyString(step.board), `${stepLabel}: board is required`) + assert(isNonEmptyString(step.instruction), `${stepLabel}: instruction is required`) + break + case 'createCapture': + if (Object.prototype.hasOwnProperty.call(step, 'board')) { + assert(isNonEmptyString(step.board), `${stepLabel}: board is present but empty`) + } + assert(isNonEmptyString(step.text), `${stepLabel}: text is required`) + break + case 'ignoreCapture': + case 'cancelCapture': + case 'triageCapture': + case 'waitForCaptureProposal': + case 'waitForCaptureOutcome': + assert(isNonEmptyString(step.capture), `${stepLabel}: capture is required`) + break + case 'executeProposal': + assert(isNonEmptyString(step.proposal), `${stepLabel}: proposal is required`) + break + case 'runOps': + assert(isNonEmptyString(step.templateName), `${stepLabel}: templateName is required`) + if (Object.prototype.hasOwnProperty.call(step, 'parameters')) { + assert( + step.parameters === null || isObject(step.parameters), + `${stepLabel}: parameters must be an object or null`, + ) + if (step.parameters !== null) { + const hasOnlyStringValues = Object.values(step.parameters).every((value) => typeof value === 'string') + assert(hasOnlyStringValues, `${stepLabel}: parameters values must all be strings`) + } + } + break + default: + break + } +} + +async function getBoardColumns(api, ctx, boardId, { force = false } = {}) { + if (!force && ctx.cache.columnsByBoardId.has(boardId)) { + return ctx.cache.columnsByBoardId.get(boardId) + } + + const columns = await api.get(`/boards/${boardId}/columns`) + const normalized = columns || [] + ctx.cache.columnsByBoardId.set(boardId, normalized) + return normalized +} + +async function getBoardLabels(api, ctx, boardId, { force = false } = {}) { + if (!force && ctx.cache.labelsByBoardId.has(boardId)) { + return ctx.cache.labelsByBoardId.get(boardId) + } + + const labels = await api.get(`/boards/${boardId}/labels`) + const normalized = labels || [] + ctx.cache.labelsByBoardId.set(boardId, normalized) + return normalized +} + +async function resolveBoardId(_api, ctx, boardRef) { + const ref = String(boardRef || '').trim() + assert(ref, 'board reference is required') + + const byAlias = ctx.refs.boards?.[ref] + if (byAlias?.id) return byAlias.id + return ref +} + +async function resolveCardId(_api, ctx, cardRef) { + const ref = String(cardRef || '').trim() + assert(ref, 'card reference is required') + + const byAlias = ctx.refs.cards?.[ref] + if (byAlias?.id) return byAlias.id + return ref +} + +async function resolveCaptureId(_api, ctx, captureRef) { + const ref = String(captureRef || '').trim() + assert(ref, 'capture reference is required') + + const byAlias = ctx.refs.captures?.[ref] + if (byAlias?.id) return byAlias.id + return ref +} + +async function resolveColumnIdByName(api, ctx, boardId, columnName) { + const columns = await getBoardColumns(api, ctx, boardId) + const matches = (columns || []).filter( + (column) => String(column?.name || '').toLowerCase() === String(columnName || '').toLowerCase(), + ) + + assert(matches.length > 0, `Column not found on board ${boardId}: "${columnName}"`) + assert( + matches.length === 1, + `Column name "${columnName}" is ambiguous on board ${boardId}: found ${matches.length} matches`, + ) + + const resolved = matches[0] + assert(resolved?.id, `Column not found on board ${boardId}: "${columnName}"`) + return resolved.id +} + +async function resolveLabelIdsByNames(api, ctx, boardId, labelNames = []) { + if (!labelNames || labelNames.length === 0) return [] + + const labels = await getBoardLabels(api, ctx, boardId) + const ids = [] + const missingLabelNames = [] + const duplicateLabelNames = [] + + for (const labelName of labelNames) { + const matches = (labels || []).filter( + (label) => String(label?.name || '').toLowerCase() === String(labelName || '').toLowerCase(), + ) + + if (matches.length === 1 && matches[0]?.id) { + ids.push(matches[0].id) + } else if (matches.length > 1) { + duplicateLabelNames.push(String(labelName || '')) + } else { + missingLabelNames.push(String(labelName || '')) + } + } + + assert( + missingLabelNames.length === 0, + `Labels not found on board ${boardId}: ${missingLabelNames.map((labelName) => `"${labelName}"`).join(', ')}`, + ) + assert( + duplicateLabelNames.length === 0, + `Label names are ambiguous on board ${boardId}: ${duplicateLabelNames.map((labelName) => `"${labelName}"`).join(', ')}`, + ) + + return ids +} + +export async function runJsonScenario({ api, config, scenario, options = {} }) { + validateScenarioJson(scenario) + + const opts = { + skipLlm: false, + continueOnError: false, + ...options, + } + + const ctx = { + api, + config, + options: opts, + warnings: [], + refs: { + boards: {}, + cards: {}, + captures: {}, + proposals: {}, + queueRequests: {}, + opsRuns: {}, + }, + cache: { + columnsByBoardId: new Map(), + labelsByBoardId: new Map(), + }, + results: { + steps: [], + }, + } + + for (const [index, rawStep] of scenario.steps.entries()) { + const step = resolveTemplates(deepClone(rawStep), ctx, `Step[${index}]`) + const label = step.label || `${index + 1}:${step.type}` + + await traceEvent({ + type: 'scenario.step.start', + scenarioId: scenario.id, + scenarioTitle: scenario.title, + stepIndex: index, + stepLabel: label, + stepType: step.type, + }) + + if (stepRequiresLlm(step) && opts.skipLlm) { + ctx.results.steps.push({ + step: label, + status: 'skipped', + reason: '--skip-llm', + }) + await traceEvent({ + type: 'scenario.step.skipped', + scenarioId: scenario.id, + stepIndex: index, + stepLabel: label, + stepType: step.type, + reason: '--skip-llm', + }) + continue + } + + try { + const result = await executeStep(api, ctx, step) + ctx.results.steps.push({ step: label, status: 'ok', result: result || null }) + await traceEvent({ + type: 'scenario.step.ok', + scenarioId: scenario.id, + stepIndex: index, + stepLabel: label, + stepType: step.type, + result: result || null, + }) + } catch (err) { + ctx.results.steps.push({ step: label, status: 'error', error: String(err?.message || err) }) + await traceEvent({ + type: 'scenario.step.error', + scenarioId: scenario.id, + stepIndex: index, + stepLabel: label, + stepType: step.type, + error: String(err?.message || err), + }) + if (!opts.continueOnError) throw err + } + } + + const boardsCreated = Object.values(ctx.refs.boards).map((board) => ({ id: board.id, name: board.name })) + const links = { + uiBoards: `${config.uiBaseUrl}/workspace/boards`, + uiInbox: `${config.uiBaseUrl}/workspace/inbox`, + uiProposals: `${config.uiBaseUrl}/workspace/automations/proposals`, + } + + if (boardsCreated.length === 1) { + links.uiBoard = `${config.uiBaseUrl}/workspace/boards/${boardsCreated[0].id}` + } + + let snapshot = null + if (boardsCreated.length >= 1) { + try { + const boardId = boardsCreated[0].id + const board = await api.get(`/boards/${boardId}`) + const columns = await api.get(`/boards/${boardId}/columns`) + const cards = await api.get(`/boards/${boardId}/cards`) + snapshot = summarizeBoardForAgent({ board, columns, cards }) + } catch (err) { + if (!opts.continueOnError) throw err + ctx.warnings.push(`Snapshot generation failed: ${String(err?.message || err)}`) + } + } + + return { + scenario: { id: scenario.id, title: scenario.title }, + boards: boardsCreated, + links, + warnings: ctx.warnings, + results: ctx.results, + snapshot, + } +} + +async function executeStep(api, ctx, step) { + switch (step.type) { + case 'createBoard': { + assert(typeof step.name === 'string' && step.name.length > 0, 'createBoard.name is required') + const board = await api.post('/boards', { + body: { + name: step.name, + description: step.description || null, + }, + }) + if (step.alias) ctx.refs.boards[step.alias] = board + return { boardId: board.id } + } + + case 'applyStarterPack': { + const boardId = await resolveBoardId(api, ctx, step.board) + assert( + typeof step.starterPackId === 'string' && step.starterPackId.length > 0, + 'applyStarterPack.starterPackId is required', + ) + + await applyStarterPack(api, { + boardId, + starterPackId: step.starterPackId, + dryRun: !!step.dryRun, + }) + + ctx.cache.columnsByBoardId.delete(boardId) + ctx.cache.labelsByBoardId.delete(boardId) + return { boardId, starterPackId: step.starterPackId } + } + + case 'createCard': { + assert(typeof step.board === 'string' && step.board.trim().length > 0, 'createCard.board is required') + assert(typeof step.column === 'string' && step.column.trim().length > 0, 'createCard.column is required') + assert(typeof step.title === 'string' && step.title.trim().length > 0, 'createCard.title is required') + const boardRef = step.board.trim() + const columnName = step.column.trim() + const boardId = await resolveBoardId(api, ctx, boardRef) + const columnId = await resolveColumnIdByName(api, ctx, boardId, columnName) + const labelIds = await resolveLabelIdsByNames(api, ctx, boardId, step.labels || []) + const dueInDays = parseOptionalFiniteNumber(step.dueInDays, 'createCard.dueInDays') + const dueDate = step.dueDate ? step.dueDate : dueInDays != null ? isoDaysFromNow(dueInDays) : null + const title = step.title.trim() + + const card = await api.post(`/boards/${boardId}/cards`, { + body: { + columnId, + title, + description: step.description || null, + dueDate, + labelIds, + }, + }) + + if (step.alias) ctx.refs.cards[step.alias] = card + return { cardId: card.id } + } + + case 'updateCard': { + const boardId = await resolveBoardId(api, ctx, step.board) + const cardId = await resolveCardId(api, ctx, step.card) + assert(isObject(step.patch), 'updateCard.patch must be an object') + + const updated = await api.patch(`/boards/${boardId}/cards/${cardId}`, { + body: step.patch, + }) + + if (step.alias) ctx.refs.cards[step.alias] = updated + return { cardId } + } + + case 'moveCard': { + const boardId = await resolveBoardId(api, ctx, step.board) + const cardId = await resolveCardId(api, ctx, step.card) + const columnId = await resolveColumnIdByName(api, ctx, boardId, step.toColumn) + + const moved = await api.post(`/boards/${boardId}/cards/${cardId}/move`, { + body: { columnId }, + }) + + if (step.alias) ctx.refs.cards[step.alias] = moved + return { cardId, columnId } + } + + case 'addComment': { + const boardId = await resolveBoardId(api, ctx, step.board) + const cardId = await resolveCardId(api, ctx, step.card) + assert(typeof step.content === 'string' && step.content.length > 0, 'addComment.content is required') + + const comment = await api.post(`/boards/${boardId}/cards/${cardId}/comments`, { + body: { content: step.content }, + }) + + return { commentId: comment.id } + } + + case 'queueInstruction': { + const boardId = await resolveBoardId(api, ctx, step.board) + assert( + typeof step.instruction === 'string' && step.instruction.length > 0, + 'queueInstruction.instruction is required', + ) + + const { request, proposal } = await enqueueAndApplyInstruction(api, { + boardId, + instruction: step.instruction, + timeoutMs: parseOptionalPositiveInteger(step.timeoutMs, 'queueInstruction.timeoutMs', 90_000), + }) + + if (step.requestAlias) ctx.refs.queueRequests[step.requestAlias] = request + if (step.proposalAlias) ctx.refs.proposals[step.proposalAlias] = proposal + return { requestId: request.id, proposalId: proposal.id } + } + + case 'createCapture': { + let boardId = null + if (Object.prototype.hasOwnProperty.call(step, 'board')) { + assert( + step.board !== null && step.board !== undefined && String(step.board).trim().length > 0, + 'createCapture.board is present but resolved to empty; check your refs', + ) + boardId = await resolveBoardId(api, ctx, step.board) + assert( + boardId !== null && boardId !== undefined && String(boardId).trim().length > 0, + 'createCapture.board resolved to an invalid boardId; check your refs', + ) + } + + assert(typeof step.text === 'string' && step.text.length > 0, 'createCapture.text is required') + + const captureItem = await createCaptureItem(api, { + boardId, + text: step.text, + source: step.source || 'Typed', + titleHint: step.titleHint || null, + externalRef: step.externalRef || null, + }) + + if (step.alias) ctx.refs.captures[step.alias] = captureItem + return { captureItemId: captureItem.id } + } + + case 'ignoreCapture': { + const captureItemId = await resolveCaptureId(api, ctx, step.capture) + await ignoreCaptureItem(api, captureItemId) + return { captureItemId } + } + + case 'cancelCapture': { + const captureItemId = await resolveCaptureId(api, ctx, step.capture) + await cancelCaptureItem(api, captureItemId) + return { captureItemId } + } + + case 'triageCapture': { + const captureItemId = await resolveCaptureId(api, ctx, step.capture) + await triageCaptureItem(api, captureItemId) + + const item = await getCaptureItem(api, captureItemId) + const captureAlias = String(step.capture || '').trim() + if (captureAlias) ctx.refs.captures[captureAlias] = item + if (step.alias) ctx.refs.captures[step.alias] = item + + return { + captureItemId, + status: item?.status, + } + } + + case 'waitForCaptureProposal': { + const captureAlias = String(step.capture || '').trim() + const captureItemId = await resolveCaptureId(api, ctx, captureAlias) + const timeoutMs = parseOptionalPositiveInteger(step.timeoutMs, 'waitForCaptureProposal.timeoutMs', 90_000) + const intervalMs = parseOptionalPositiveInteger(step.intervalMs, 'waitForCaptureProposal.intervalMs', 1200) + + const proposalId = await waitForCaptureProposalId(api, captureItemId, { timeoutMs, intervalMs }) + const item = await getCaptureItem(api, captureItemId) + if (captureAlias) ctx.refs.captures[captureAlias] = item + + ctx.refs.proposals[step.proposalAlias || `${captureAlias}:proposal`] = { id: proposalId } + return { captureItemId, proposalId } + } + + case 'waitForCaptureOutcome': { + const captureAlias = String(step.capture || '').trim() + const captureItemId = await resolveCaptureId(api, ctx, captureAlias) + + const outcome = await waitForCaptureOutcome(api, captureItemId, { + timeoutMs: parseOptionalPositiveInteger(step.timeoutMs, 'waitForCaptureOutcome.timeoutMs', 90_000), + intervalMs: parseOptionalPositiveInteger(step.intervalMs, 'waitForCaptureOutcome.intervalMs', 1200), + }) + + if (captureAlias) ctx.refs.captures[captureAlias] = outcome.item + if (step.alias) ctx.refs.captures[step.alias] = outcome.item + return { + captureItemId, + outcome: outcome.outcome, + status: outcome?.item?.status, + } + } + + case 'executeProposal': { + assert(typeof step.proposal === 'string' && step.proposal.length > 0, 'executeProposal.proposal is required') + const proposalRef = step.proposal.trim() + const proposal = ctx.refs.proposals?.[proposalRef] + const proposalId = proposal?.id || (UUID_RE.test(proposalRef) ? proposalRef : null) + + if (!proposalId) { + throw new Error( + `executeProposal: "${proposalRef}" did not resolve to a proposal ID. ` + + `This usually means a prior proposal-producing step (waitForCaptureProposal, queueInstruction) was skipped (e.g. --skip-llm). ` + + `Mark this step with "requiresLlm": true to skip it automatically.`, + ) + } + + await approveAndExecuteProposal(api, proposalId) + return { proposalId } + } + + case 'runOps': { + assert(isNonEmptyString(step.templateName), 'runOps.templateName is required') + const run = await runOpsCommand(api, { + templateName: step.templateName, + parameters: step.parameters || null, + }) + + const alias = isNonEmptyString(step.alias) ? step.alias.trim() : null + if (alias) ctx.refs.opsRuns[alias] = run + + if (step.wait === false) { + return { runId: run?.id || null, status: run?.status ?? null } + } + + const runId = run?.id + assert(isNonEmptyString(runId), 'runOps returned an invalid run id') + const done = await waitForOpsRun(api, runId, { + timeoutMs: parseOptionalPositiveInteger(step.timeoutMs, 'runOps.timeoutMs', 60_000), + intervalMs: parseOptionalPositiveInteger(step.intervalMs, 'runOps.intervalMs', 700), + }) + if (alias) ctx.refs.opsRuns[alias] = done + + const finalStatus = normalizeOpsRunStatus(done?.status) + if (isOpsRunFailureStatus(finalStatus)) { + const detail = isNonEmptyString(done?.errorMessage) ? `: ${done.errorMessage}` : '' + throw new Error(`Ops run ${runId} finished with non-success status ${finalStatus}${detail}`) + } + + const logs = step.includeLogs ? await getOpsRunLogs(api, runId) : null + const logsCount = Array.isArray(logs) ? logs.length : null + const logsPreview = Array.isArray(logs) ? logs.slice(0, OPS_LOG_PREVIEW_LIMIT) : null + if (alias && ctx.refs.opsRuns[alias]) { + ctx.refs.opsRuns[alias].logsCount = logsCount + ctx.refs.opsRuns[alias].logsPreview = logsPreview + } + + return { + runId, + status: finalStatus, + exitCode: done?.exitCode ?? null, + logsCount, + logsPreview, + } + } + + default: + throw new Error(`Unknown scenario step type: ${step.type}`) + } +} diff --git a/frontend/taskdeck-web/scripts/scenarios-json/content-calendar.json b/frontend/taskdeck-web/scripts/scenarios-json/content-calendar.json index 4038e19f2..e9fd7015f 100644 --- a/frontend/taskdeck-web/scripts/scenarios-json/content-calendar.json +++ b/frontend/taskdeck-web/scripts/scenarios-json/content-calendar.json @@ -1,74 +1,74 @@ -{ - "version": 1, - "id": "content-calendar", - "title": "Content Calendar", +{ + "version": 1, + "id": "content-calendar", + "title": "Content Calendar", "description": "Seeded content pipeline: ideas -> drafting -> review -> scheduled. Includes a queue-driven move.", - "tags": [ - "demo", - "content", - "pipeline" - ], - "steps": [ - { - "type": "createBoard", - "alias": "board", - "name": "DEMO: Content Calendar Scenario", + "tags": [ + "demo", + "content", + "pipeline" + ], + "steps": [ + { + "type": "createBoard", + "alias": "board", + "name": "DEMO: Content Calendar Scenario", "description": "Seeded content pipeline: ideas -> drafting -> review -> scheduled." - }, - { - "type": "applyStarterPack", - "board": "board", - "starterPackId": "board-blueprint-content-calendar" - }, - { - "type": "createCard", - "alias": "ideaBlog", - "board": "board", - "column": "Ideas", - "title": "Blog: Why proposal-first automations are safer", + }, + { + "type": "applyStarterPack", + "board": "board", + "starterPackId": "board-blueprint-content-calendar" + }, + { + "type": "createCard", + "alias": "ideaBlog", + "board": "board", + "column": "Ideas", + "title": "Blog: Why proposal-first automations are safer", "description": "Explain review/approve/execute flow. Compare to \"autopilot\" approaches.", "labels": [ "needs-draft" ] - }, - { - "type": "createCard", - "alias": "draftRelease", - "board": "board", - "column": "Drafting", - "title": "Release notes draft: Capture Loop MVP", - "description": "Summarize inbox capture -> triage -> proposal -> apply. Include screenshots.", - "dueInDays": 3, + }, + { + "type": "createCard", + "alias": "draftRelease", + "board": "board", + "column": "Drafting", + "title": "Release notes draft: Capture Loop MVP", + "description": "Summarize inbox capture -> triage -> proposal -> apply. Include screenshots.", + "dueInDays": 3, "labels": [ "needs-draft", "publish-week" ] - }, - { - "type": "createCard", - "alias": "designEmptyState", - "board": "board", - "column": "Review", - "title": "Design: Automations empty-state panel", - "description": "3 example prompts + explanation of Queue vs Proposals vs Chat.", + }, + { + "type": "createCard", + "alias": "designEmptyState", + "board": "board", + "column": "Review", + "title": "Design: Automations empty-state panel", + "description": "3 example prompts + explanation of Queue vs Proposals vs Chat.", "labels": [ "needs-review" ] - }, - { - "type": "createCard", - "alias": "scheduledTweet", - "board": "board", - "column": "Scheduled", - "title": "Tweet thread: Taskdeck demo walkthrough", - "description": "Short series showing boards -> inbox -> triage -> proposals -> execute.", - "dueInDays": 1, + }, + { + "type": "createCard", + "alias": "scheduledTweet", + "board": "board", + "column": "Scheduled", + "title": "Tweet thread: Taskdeck demo walkthrough", + "description": "Short series showing boards -> inbox -> triage -> proposals -> execute.", + "dueInDays": 1, "labels": [ "publish-week" ] - }, - { - "type": "createCard", + }, + { + "type": "createCard", "alias": "scheduledStarterPacks", "board": "board", "column": "Scheduled", @@ -77,21 +77,21 @@ "labels": [ "publish-week" ] - }, - { - "type": "queueInstruction", - "board": "board", - "instruction": "move card ${cards.designEmptyState.id} to column \"Scheduled\"", - "requiresLlm": true, - "requestAlias": "qMove", - "proposalAlias": "pMove" - }, - { - "type": "runOps", - "label": "Ops: health.check", - "templateName": "health.check", - "includeLogs": true, - "alias": "opsHealth" - } - ] -} + }, + { + "type": "queueInstruction", + "board": "board", + "instruction": "move card ${cards.designEmptyState.id} to column \"Scheduled\"", + "requiresLlm": true, + "requestAlias": "qMove", + "proposalAlias": "pMove" + }, + { + "type": "runOps", + "label": "Ops: health.check", + "templateName": "health.check", + "includeLogs": true, + "alias": "opsHealth" + } + ] +} diff --git a/frontend/taskdeck-web/scripts/scenarios/content-calendar.mjs b/frontend/taskdeck-web/scripts/scenarios/content-calendar.mjs index 50088b1b3..677f7aa1d 100644 --- a/frontend/taskdeck-web/scripts/scenarios/content-calendar.mjs +++ b/frontend/taskdeck-web/scripts/scenarios/content-calendar.mjs @@ -1,32 +1,32 @@ -import { - applyStarterPack, - enqueueAndApplyInstruction, - getDemoConfig, - isoDaysFromNow, - summarizeBoardForAgent, -} from '../demo-lib.mjs' - -export async function run({ api, config: cfg }) { - const config = cfg || getDemoConfig() - +import { + applyStarterPack, + enqueueAndApplyInstruction, + getDemoConfig, + isoDaysFromNow, + summarizeBoardForAgent, +} from '../demo-lib.mjs' + +export async function run({ api, config: cfg }) { + const config = cfg || getDemoConfig() + const board = await api.post('/boards', { body: { name: 'DEMO: Content Calendar Scenario', description: 'Seeded content pipeline: ideas -> drafting -> review -> scheduled.', }, }) - - await applyStarterPack(api, { - boardId: board.id, - starterPackId: 'board-blueprint-content-calendar', - dryRun: false, - }) - - const columns = await api.get(`/boards/${board.id}/columns`) - const labels = await api.get(`/boards/${board.id}/labels`) - const byColumn = new Map((columns || []).map((column) => [column.name, column])) - const byLabel = new Map((labels || []).map((label) => [label.name, label])) - + + await applyStarterPack(api, { + boardId: board.id, + starterPackId: 'board-blueprint-content-calendar', + dryRun: false, + }) + + const columns = await api.get(`/boards/${board.id}/columns`) + const labels = await api.get(`/boards/${board.id}/labels`) + const byColumn = new Map((columns || []).map((column) => [column.name, column])) + const byLabel = new Map((labels || []).map((label) => [label.name, label])) + const ideas = byColumn.get('Ideas') const drafting = byColumn.get('Drafting') const review = byColumn.get('Review') @@ -41,18 +41,18 @@ export async function run({ api, config: cfg }) { if (!needsDraft || !needsReview || !publishWeek) { throw new Error('Starter pack did not create expected content labels.') } - - await api.post(`/boards/${board.id}/cards`, { - body: { + + await api.post(`/boards/${board.id}/cards`, { + body: { columnId: ideas.id, title: 'Blog: Why proposal-first automations are safer', description: 'Explain review/approve/execute flow. Compare to autopilot approaches.', labelIds: [needsDraft.id], }, }) - - await api.post(`/boards/${board.id}/cards`, { - body: { + + await api.post(`/boards/${board.id}/cards`, { + body: { columnId: drafting.id, title: 'Release notes draft: Capture Loop MVP', description: 'Summarize Inbox capture -> triage -> proposal -> apply. Include screenshots.', @@ -60,18 +60,18 @@ export async function run({ api, config: cfg }) { labelIds: [needsDraft.id, publishWeek.id], }, }) - - const cardForReview = await api.post(`/boards/${board.id}/cards`, { - body: { + + const cardForReview = await api.post(`/boards/${board.id}/cards`, { + body: { columnId: review.id, title: 'Design: Automations empty-state panel', description: '3 example prompts + explanation of Queue vs Proposals vs Chat.', labelIds: [needsReview.id], }, }) - - await api.post(`/boards/${board.id}/cards`, { - body: { + + await api.post(`/boards/${board.id}/cards`, { + body: { columnId: scheduled.id, title: 'Tweet thread: Taskdeck demo walkthrough', description: 'Short series showing boards -> inbox -> triage -> proposals -> execute.', @@ -88,18 +88,18 @@ export async function run({ api, config: cfg }) { labelIds: [publishWeek.id], }, }) - - const instruction = `move card ${cardForReview.id} to column "Scheduled"` - await enqueueAndApplyInstruction(api, { boardId: board.id, instruction }) - - const cards = await api.get(`/boards/${board.id}/cards`) - const snapshot = summarizeBoardForAgent({ board, columns, cards }) - - return { - board: { id: board.id, name: board.name }, - links: { - uiBoard: `${config.uiBaseUrl}/workspace/boards/${board.id}`, - }, - snapshot, - } -} + + const instruction = `move card ${cardForReview.id} to column "Scheduled"` + await enqueueAndApplyInstruction(api, { boardId: board.id, instruction }) + + const cards = await api.get(`/boards/${board.id}/cards`) + const snapshot = summarizeBoardForAgent({ board, columns, cards }) + + return { + board: { id: board.id, name: board.name }, + links: { + uiBoard: `${config.uiBaseUrl}/workspace/boards/${board.id}`, + }, + snapshot, + } +} diff --git a/frontend/taskdeck-web/scripts/scenarios/engineering-sprint.mjs b/frontend/taskdeck-web/scripts/scenarios/engineering-sprint.mjs index 1e0196a22..e2c85eee4 100644 --- a/frontend/taskdeck-web/scripts/scenarios/engineering-sprint.mjs +++ b/frontend/taskdeck-web/scripts/scenarios/engineering-sprint.mjs @@ -1,109 +1,109 @@ -import { - applyStarterPack, - enqueueAndApplyInstruction, - getDemoConfig, - isoDaysFromNow, - summarizeBoardForAgent, -} from '../demo-lib.mjs' - -export async function run({ api, config: cfg }) { - const config = cfg || getDemoConfig() - - const board = await api.post('/boards', { - body: { - name: 'DEMO: Engineering Sprint', - description: - 'A realistic sprint board seeded for demos and testing. Includes labels, due dates, a blocked item, and a queue-driven automation.', - }, - }) - - await applyStarterPack(api, { - boardId: board.id, - starterPackId: 'board-blueprint-engineering-sprint', - dryRun: false, - }) - - const columns = await api.get(`/boards/${board.id}/columns`) - const labels = await api.get(`/boards/${board.id}/labels`) - const byColumn = new Map((columns || []).map((column) => [column.name, column])) - const byLabel = new Map((labels || []).map((label) => [label.name, label])) - - const backlog = byColumn.get('Backlog') - const inProgress = byColumn.get('In Progress') - const review = byColumn.get('Review') - if (!backlog || !inProgress || !review) { - throw new Error('Starter pack did not create expected columns (Backlog/In Progress/Review).') - } - +import { + applyStarterPack, + enqueueAndApplyInstruction, + getDemoConfig, + isoDaysFromNow, + summarizeBoardForAgent, +} from '../demo-lib.mjs' + +export async function run({ api, config: cfg }) { + const config = cfg || getDemoConfig() + + const board = await api.post('/boards', { + body: { + name: 'DEMO: Engineering Sprint', + description: + 'A realistic sprint board seeded for demos and testing. Includes labels, due dates, a blocked item, and a queue-driven automation.', + }, + }) + + await applyStarterPack(api, { + boardId: board.id, + starterPackId: 'board-blueprint-engineering-sprint', + dryRun: false, + }) + + const columns = await api.get(`/boards/${board.id}/columns`) + const labels = await api.get(`/boards/${board.id}/labels`) + const byColumn = new Map((columns || []).map((column) => [column.name, column])) + const byLabel = new Map((labels || []).map((label) => [label.name, label])) + + const backlog = byColumn.get('Backlog') + const inProgress = byColumn.get('In Progress') + const review = byColumn.get('Review') + if (!backlog || !inProgress || !review) { + throw new Error('Starter pack did not create expected columns (Backlog/In Progress/Review).') + } + const bug = byLabel.get('bug') const techDebt = byLabel.get('tech-debt') const priorityHigh = byLabel.get('priority-high') if (!bug || !techDebt || !priorityHigh) { throw new Error('Starter pack did not create expected labels (bug/tech-debt/priority-high).') } - - const card1 = await api.post(`/boards/${board.id}/cards`, { - body: { - columnId: backlog.id, - title: 'Fix: login error state resets unexpectedly', - description: 'Repro: failed login -> error toast -> retry with correct password -> form clears too early.', - dueDate: isoDaysFromNow(2), + + const card1 = await api.post(`/boards/${board.id}/cards`, { + body: { + columnId: backlog.id, + title: 'Fix: login error state resets unexpectedly', + description: 'Repro: failed login -> error toast -> retry with correct password -> form clears too early.', + dueDate: isoDaysFromNow(2), labelIds: [bug.id, priorityHigh.id], - }, - }) - - const card2 = await api.post(`/boards/${board.id}/cards`, { - body: { - columnId: inProgress.id, - title: 'Refactor: consolidate API error mapping', - description: 'Unify API error payload parsing across views; standardize toast messaging.', - dueDate: isoDaysFromNow(4), + }, + }) + + const card2 = await api.post(`/boards/${board.id}/cards`, { + body: { + columnId: inProgress.id, + title: 'Refactor: consolidate API error mapping', + description: 'Unify API error payload parsing across views; standardize toast messaging.', + dueDate: isoDaysFromNow(4), labelIds: [techDebt.id], - }, - }) - - const card3 = await api.post(`/boards/${board.id}/cards`, { - body: { - columnId: review.id, - title: 'Add: empty-state guidance for Automations', - description: 'Help users understand Queue vs Proposals vs Chat. Show 3 copy-paste examples.', + }, + }) + + const card3 = await api.post(`/boards/${board.id}/cards`, { + body: { + columnId: review.id, + title: 'Add: empty-state guidance for Automations', + description: 'Help users understand Queue vs Proposals vs Chat. Show 3 copy-paste examples.', labelIds: [priorityHigh.id], - }, - }) - - await api.patch(`/boards/${board.id}/cards/${card2.id}`, { - body: { - isBlocked: true, - blockReason: 'Waiting on decision: should Queue composer require board selection?', - }, - }) - - await api.post(`/boards/${board.id}/cards/${card1.id}/comments`, { - body: { - content: 'If this regresses again, add an E2E test around login error handling.', - }, - }) - - await api.post(`/boards/${board.id}/cards/${card3.id}/comments`, { - body: { - content: 'Demo tip: open this card, then show Automations -> Proposals to highlight review/execute.', - }, - }) - - const instruction = - 'create card "Spike: simulate LLM-driven user" in column "Backlog" with description ' + - '"Add an agent runner that creates/moves tasks like a real user."' - await enqueueAndApplyInstruction(api, { boardId: board.id, instruction }) - - const cards = await api.get(`/boards/${board.id}/cards`) - const snapshot = summarizeBoardForAgent({ board, columns, cards }) - - return { - board: { id: board.id, name: board.name }, - links: { - uiBoard: `${config.uiBaseUrl}/workspace/boards/${board.id}`, - uiAutomations: `${config.uiBaseUrl}/workspace/automations/proposals`, - }, - snapshot, - } -} + }, + }) + + await api.patch(`/boards/${board.id}/cards/${card2.id}`, { + body: { + isBlocked: true, + blockReason: 'Waiting on decision: should Queue composer require board selection?', + }, + }) + + await api.post(`/boards/${board.id}/cards/${card1.id}/comments`, { + body: { + content: 'If this regresses again, add an E2E test around login error handling.', + }, + }) + + await api.post(`/boards/${board.id}/cards/${card3.id}/comments`, { + body: { + content: 'Demo tip: open this card, then show Automations -> Proposals to highlight review/execute.', + }, + }) + + const instruction = + 'create card "Spike: simulate LLM-driven user" in column "Backlog" with description ' + + '"Add an agent runner that creates/moves tasks like a real user."' + await enqueueAndApplyInstruction(api, { boardId: board.id, instruction }) + + const cards = await api.get(`/boards/${board.id}/cards`) + const snapshot = summarizeBoardForAgent({ board, columns, cards }) + + return { + board: { id: board.id, name: board.name }, + links: { + uiBoard: `${config.uiBaseUrl}/workspace/boards/${board.id}`, + uiAutomations: `${config.uiBaseUrl}/workspace/automations/proposals`, + }, + snapshot, + } +} diff --git a/frontend/taskdeck-web/src/views/BoardAccessView.vue b/frontend/taskdeck-web/src/views/BoardAccessView.vue index 745a649df..b18cb9935 100644 --- a/frontend/taskdeck-web/src/views/BoardAccessView.vue +++ b/frontend/taskdeck-web/src/views/BoardAccessView.vue @@ -1,522 +1,522 @@ - - - - - + + + + + diff --git a/frontend/taskdeck-web/tests/demo-live-llm.spec.ts b/frontend/taskdeck-web/tests/demo-live-llm.spec.ts index 40ca91eff..a87c2323f 100644 --- a/frontend/taskdeck-web/tests/demo-live-llm.spec.ts +++ b/frontend/taskdeck-web/tests/demo-live-llm.spec.ts @@ -1,45 +1,45 @@ import { describe, expect, it } from 'vitest' import { resolveDemoBackendLlmEnv, resolvePlaywrightBackendLlmEnv } from '../playwright.demo-llm' - + describe('demo live llm env resolution', () => { it('auto-enables Gemini for full demo runs when a Gemini key is present', () => { const env = resolveDemoBackendLlmEnv({ TASKDECK_RUN_DEMO: '1', GEMINI_API_KEY: 'gemini-key', - }) - - expect(env).toEqual({ - Llm__EnableLiveProviders: 'true', - Llm__AllowLiveProvidersInDevelopment: 'true', - Llm__Provider: 'Gemini', - Llm__Gemini__ApiKey: 'gemini-key', - }) - }) - - it('keeps deterministic demo smoke runs on mock by skipping live overrides when llm steps are disabled', () => { - const env = resolveDemoBackendLlmEnv({ - TASKDECK_RUN_DEMO: '1', - TASKDECK_DEMO_SKIP_LLM: '1', - GEMINI_API_KEY: 'gemini-key', - }) - - expect(env).toEqual({}) - }) - + }) + + expect(env).toEqual({ + Llm__EnableLiveProviders: 'true', + Llm__AllowLiveProvidersInDevelopment: 'true', + Llm__Provider: 'Gemini', + Llm__Gemini__ApiKey: 'gemini-key', + }) + }) + + it('keeps deterministic demo smoke runs on mock by skipping live overrides when llm steps are disabled', () => { + const env = resolveDemoBackendLlmEnv({ + TASKDECK_RUN_DEMO: '1', + TASKDECK_DEMO_SKIP_LLM: '1', + GEMINI_API_KEY: 'gemini-key', + }) + + expect(env).toEqual({}) + }) + it('allows forcing OpenAI for full demos through the demo provider override', () => { const env = resolveDemoBackendLlmEnv({ TASKDECK_RUN_DEMO: '1', TASKDECK_DEMO_LLM_PROVIDER: 'OpenAI', OPENAI_API_KEY: 'openai-key', - TASKDECK_DEMO_OPENAI_MODEL: 'gpt-4o-mini', - }) - - expect(env).toEqual({ - Llm__EnableLiveProviders: 'true', - Llm__AllowLiveProvidersInDevelopment: 'true', - Llm__Provider: 'OpenAI', - Llm__OpenAi__ApiKey: 'openai-key', + TASKDECK_DEMO_OPENAI_MODEL: 'gpt-4o-mini', + }) + + expect(env).toEqual({ + Llm__EnableLiveProviders: 'true', + Llm__AllowLiveProvidersInDevelopment: 'true', + Llm__Provider: 'OpenAI', + Llm__OpenAi__ApiKey: 'openai-key', Llm__OpenAi__Model: 'gpt-4o-mini', }) }) diff --git a/frontend/taskdeck-web/tests/e2e/automation-ops.spec.ts b/frontend/taskdeck-web/tests/e2e/automation-ops.spec.ts index 8f70f2fb5..b217acfe8 100644 --- a/frontend/taskdeck-web/tests/e2e/automation-ops.spec.ts +++ b/frontend/taskdeck-web/tests/e2e/automation-ops.spec.ts @@ -1,134 +1,134 @@ -import type { APIRequestContext } from '@playwright/test' -import { expect, test } from '@playwright/test' -import { parseTrueishEnv } from '../../scripts/demo-shared.mjs' -import { API_BASE_URL, registerAndAttachSession, type AuthResult } from './support/authSession' -import { createBoardWithColumn } from './support/boardHelpers' -import { assertOk } from './support/httpAsserts' -import { pollUntil } from './support/polling' - -interface ChatMessageDto { - proposalId: string | null -} - -interface ChatSessionDto { - recentMessages: ChatMessageDto[] -} - -interface ProposalDto { - id: string - summary: string -} - -async function waitForProposalInSession( - request: APIRequestContext, - token: string, - sessionId: string, -): Promise { - const sessionWithProposal = await pollUntil( - async () => { - const response = await request.get(`${API_BASE_URL}/llm/chat/sessions/${encodeURIComponent(sessionId)}`, { - headers: { Authorization: `Bearer ${token}` }, - }) - await assertOk(response, `fetch chat session ${sessionId}`) - return await response.json() as ChatSessionDto - }, - (session) => session.recentMessages.some((m) => !!m.proposalId), - { description: 'proposal reference in chat session' }, - ) - - const proposalId = sessionWithProposal.recentMessages.find((m) => !!m.proposalId)?.proposalId - if (!proposalId) { - throw new Error('Expected a proposal reference in chat session') - } - - return proposalId -} - -let auth: AuthResult - -test.beforeEach(async ({ page, request }) => { - auth = await registerAndAttachSession(page, request, 'ops') -}) - -test('chat session should create and return assistant response', async ({ page }) => { - await page.goto('/workspace/automations/chat') - const expectLiveProvider = parseTrueishEnv(process.env.TASKDECK_RUN_LIVE_LLM_TESTS) - - if (expectLiveProvider) { - await expect(page.locator('[data-llm-health-state="configured"]')).toBeVisible() - await expect(page.getByText('Live LLM configured')).toBeVisible() - } else { - await expect(page.locator('[data-llm-health-state="mock"]')).toBeVisible() - await expect(page.getByText('Live LLM not active')).toBeVisible() - } - - await page.getByPlaceholder('Session title').fill(`Session ${Date.now()}`) - await page.getByRole('button', { name: 'Create Session' }).click() - - await expect(page.getByText('Session', { exact: false }).first()).toBeVisible() - - await page.getByPlaceholder('Describe an automation instruction...').fill('summarize this board status') - await page.getByRole('button', { name: 'Send Message' }).click() - - await expect(page.getByText('Assistant').first()).toBeVisible() -}) - -test('ops cli should run health.check template', async ({ page }) => { - await page.goto('/workspace/ops/cli') - - await expect(page.getByRole('heading', { name: 'Ops Console' })).toBeVisible() - - const templateInput = page.getByRole('combobox', { name: 'Command template' }) - await templateInput.fill('health.check') - await page.getByRole('button', { name: 'Run Template' }).click() - - await expect(page.getByText('Health check: OK')).toBeVisible() -}) - -test('chat proposal flow should create, approve, and execute proposal', async ({ page, request }) => { - const seed = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}` - const boardId = await createBoardWithColumn(request, auth, seed, { - boardNamePrefix: 'Automation E2E', - description: 'automation e2e board', - columnNamePrefix: 'Backlog', - }) - const uniqueCardTitle = `E2E Card ${seed}` - - await page.goto('/workspace/automations/chat') - await page.getByPlaceholder('Session title').fill(`Proposal Session ${seed}`) - await page.getByPlaceholder('Board context (optional)').fill(boardId) - await page.getByRole('button', { name: 'Create Session' }).click() - - const sessionId = await page.locator('.td-chat-meta').first().getAttribute('data-session-id') - if (!sessionId) { - throw new Error('Expected chat session header to expose data-session-id') - } - - await page.getByPlaceholder('Describe an automation instruction...').fill(`create card "${uniqueCardTitle}"`) - const requestProposalCheckbox = page.getByRole('checkbox', { name: 'Request proposal generation' }) - await requestProposalCheckbox.check() - await expect(requestProposalCheckbox).toBeChecked() - await page.getByRole('button', { name: 'Send Message' }).click() - - const proposalId = await waitForProposalInSession(request, auth.token, sessionId) - await expect(page.locator('.td-message-proposal').filter({ hasText: proposalId }).first()).toBeVisible() - - const proposalResponse = await request.get(`${API_BASE_URL}/automation/proposals/${encodeURIComponent(proposalId)}`, { - headers: { Authorization: `Bearer ${auth.token}` }, - }) - await assertOk(proposalResponse, `fetch proposal ${proposalId}`) - const proposal = await proposalResponse.json() as ProposalDto - - await page.goto('/workspace/review') - await expect(page.getByRole('heading', { name: 'Review', exact: true })).toBeVisible() - - const proposalCard = page.locator('.td-review-card').filter({ hasText: proposal.summary }).first() - await expect(proposalCard).toBeVisible() - - await proposalCard.getByRole('button', { name: 'Approve for board' }).click() - await expect(proposalCard.getByText('Approved, ready to apply')).toBeVisible() - - page.once('dialog', (dialog) => dialog.accept()) - await proposalCard.getByRole('button', { name: 'Apply to board' }).click() - await expect(proposalCard).not.toBeVisible() -}) +import type { APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { parseTrueishEnv } from '../../scripts/demo-shared.mjs' +import { API_BASE_URL, registerAndAttachSession, type AuthResult } from './support/authSession' +import { createBoardWithColumn } from './support/boardHelpers' +import { assertOk } from './support/httpAsserts' +import { pollUntil } from './support/polling' + +interface ChatMessageDto { + proposalId: string | null +} + +interface ChatSessionDto { + recentMessages: ChatMessageDto[] +} + +interface ProposalDto { + id: string + summary: string +} + +async function waitForProposalInSession( + request: APIRequestContext, + token: string, + sessionId: string, +): Promise { + const sessionWithProposal = await pollUntil( + async () => { + const response = await request.get(`${API_BASE_URL}/llm/chat/sessions/${encodeURIComponent(sessionId)}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + await assertOk(response, `fetch chat session ${sessionId}`) + return await response.json() as ChatSessionDto + }, + (session) => session.recentMessages.some((m) => !!m.proposalId), + { description: 'proposal reference in chat session' }, + ) + + const proposalId = sessionWithProposal.recentMessages.find((m) => !!m.proposalId)?.proposalId + if (!proposalId) { + throw new Error('Expected a proposal reference in chat session') + } + + return proposalId +} + +let auth: AuthResult + +test.beforeEach(async ({ page, request }) => { + auth = await registerAndAttachSession(page, request, 'ops') +}) + +test('chat session should create and return assistant response', async ({ page }) => { + await page.goto('/workspace/automations/chat') + const expectLiveProvider = parseTrueishEnv(process.env.TASKDECK_RUN_LIVE_LLM_TESTS) + + if (expectLiveProvider) { + await expect(page.locator('[data-llm-health-state="configured"]')).toBeVisible() + await expect(page.getByText('Live LLM configured')).toBeVisible() + } else { + await expect(page.locator('[data-llm-health-state="mock"]')).toBeVisible() + await expect(page.getByText('Live LLM not active')).toBeVisible() + } + + await page.getByPlaceholder('Session title').fill(`Session ${Date.now()}`) + await page.getByRole('button', { name: 'Create Session' }).click() + + await expect(page.getByText('Session', { exact: false }).first()).toBeVisible() + + await page.getByPlaceholder('Describe an automation instruction...').fill('summarize this board status') + await page.getByRole('button', { name: 'Send Message' }).click() + + await expect(page.getByText('Assistant').first()).toBeVisible() +}) + +test('ops cli should run health.check template', async ({ page }) => { + await page.goto('/workspace/ops/cli') + + await expect(page.getByRole('heading', { name: 'Ops Console' })).toBeVisible() + + const templateInput = page.getByRole('combobox', { name: 'Command template' }) + await templateInput.fill('health.check') + await page.getByRole('button', { name: 'Run Template' }).click() + + await expect(page.getByText('Health check: OK')).toBeVisible() +}) + +test('chat proposal flow should create, approve, and execute proposal', async ({ page, request }) => { + const seed = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}` + const boardId = await createBoardWithColumn(request, auth, seed, { + boardNamePrefix: 'Automation E2E', + description: 'automation e2e board', + columnNamePrefix: 'Backlog', + }) + const uniqueCardTitle = `E2E Card ${seed}` + + await page.goto('/workspace/automations/chat') + await page.getByPlaceholder('Session title').fill(`Proposal Session ${seed}`) + await page.getByPlaceholder('Board context (optional)').fill(boardId) + await page.getByRole('button', { name: 'Create Session' }).click() + + const sessionId = await page.locator('.td-chat-meta').first().getAttribute('data-session-id') + if (!sessionId) { + throw new Error('Expected chat session header to expose data-session-id') + } + + await page.getByPlaceholder('Describe an automation instruction...').fill(`create card "${uniqueCardTitle}"`) + const requestProposalCheckbox = page.getByRole('checkbox', { name: 'Request proposal generation' }) + await requestProposalCheckbox.check() + await expect(requestProposalCheckbox).toBeChecked() + await page.getByRole('button', { name: 'Send Message' }).click() + + const proposalId = await waitForProposalInSession(request, auth.token, sessionId) + await expect(page.locator('.td-message-proposal').filter({ hasText: proposalId }).first()).toBeVisible() + + const proposalResponse = await request.get(`${API_BASE_URL}/automation/proposals/${encodeURIComponent(proposalId)}`, { + headers: { Authorization: `Bearer ${auth.token}` }, + }) + await assertOk(proposalResponse, `fetch proposal ${proposalId}`) + const proposal = await proposalResponse.json() as ProposalDto + + await page.goto('/workspace/review') + await expect(page.getByRole('heading', { name: 'Review', exact: true })).toBeVisible() + + const proposalCard = page.locator('.td-review-card').filter({ hasText: proposal.summary }).first() + await expect(proposalCard).toBeVisible() + + await proposalCard.getByRole('button', { name: 'Approve for board' }).click() + await expect(proposalCard.getByText('Approved, ready to apply')).toBeVisible() + + page.once('dialog', (dialog) => dialog.accept()) + await proposalCard.getByRole('button', { name: 'Apply to board' }).click() + await expect(proposalCard).not.toBeVisible() +}) diff --git a/frontend/taskdeck-web/tests/e2e/capture-loop.spec.ts b/frontend/taskdeck-web/tests/e2e/capture-loop.spec.ts index 581a854f3..2cff5bca8 100644 --- a/frontend/taskdeck-web/tests/e2e/capture-loop.spec.ts +++ b/frontend/taskdeck-web/tests/e2e/capture-loop.spec.ts @@ -1,85 +1,85 @@ -import { expect, test } from '@playwright/test' -import { registerAndAttachSession, type AuthResult } from './support/authSession' -import { createBoardWithColumn } from './support/boardHelpers' -import { - createCaptureItem, - listBoardCards, - waitForCardWithTitle, - waitForProposalCreated, -} from './support/captureFlow' - -let auth: AuthResult - -test.beforeEach(async ({ page, request }) => { - auth = await registerAndAttachSession(page, request, 'capture-loop') -}) - -test('capture triage should create proposal and apply card with provenance links', async ({ page, request }) => { - const seed = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}` - const boardId = await createBoardWithColumn(request, auth, seed, { - boardNamePrefix: 'Capture Loop', - description: 'capture triage e2e board', - columnNamePrefix: 'Inbox', - }) - const checklistTaskTitle = `Capture loop card ${seed}` - const captureText = `- [ ] ${checklistTaskTitle}` - - const createdCapture = await createCaptureItem(request, auth, boardId, captureText) - const captureId = createdCapture.id - - await page.goto('/workspace/inbox') - const captureRow = page.locator('[data-testid="inbox-item"]').filter({ hasText: checklistTaskTitle }).first() - await expect(captureRow).toBeVisible() - await captureRow.click() - - const triageButton = page.locator('.td-inbox-detail__actions button').filter({ hasText: 'Start Triage' }).first() - await expect(triageButton).toBeVisible() - await triageButton.click() - - const triagedCapture = await waitForProposalCreated(request, auth, captureId) - const proposalId = triagedCapture.provenance?.proposalId - const triageRunId = triagedCapture.provenance?.triageRunId - expect(proposalId).toBeTruthy() - const cardsAfterTriage = await listBoardCards(request, auth, boardId) - expect(cardsAfterTriage.length).toBe(0) - - await page.getByRole('button', { name: 'Refresh Detail' }).click() - const openProposalButton = page.getByRole('button', { name: 'Open in Review' }) - await expect(openProposalButton).toBeVisible() - await openProposalButton.click() - - await expect(page).toHaveURL(new RegExp(`/workspace/review\\?boardId=${boardId}#proposal-${proposalId}`)) - const proposalCard = page.locator(`#proposal-${proposalId}`) - await expect(proposalCard).toBeVisible() - - await proposalCard.getByRole('button', { name: 'Approve for board' }).click() - await expect(proposalCard.getByText('Approved, ready to apply')).toBeVisible() - const cardsAfterApprove = await listBoardCards(request, auth, boardId) - expect(cardsAfterApprove.length).toBe(0) - - page.once('dialog', (dialog) => dialog.accept()) - await proposalCard.getByRole('button', { name: 'Apply to board' }).click() - await expect(proposalCard).not.toBeVisible() - - const createdCard = await waitForCardWithTitle(request, auth, boardId, checklistTaskTitle) - - await page.goto(`/workspace/boards/${boardId}`) - const card = page.locator('[data-card-id]').filter({ hasText: createdCard.title }).first() - await expect(card).toBeVisible() - await card.getByRole('heading', { name: createdCard.title, exact: true }).click() - - await expect(page.getByRole('heading', { name: 'Edit Card' })).toBeVisible() - await expect(page.getByText('Capture Origin')).toBeVisible() - await expect(page.getByRole('link', { name: 'Open Capture' })).toHaveAttribute( - 'href', - `/workspace/inbox?boardId=${boardId}#capture-${captureId}`, - ) - await expect(page.getByRole('link', { name: 'Open Proposal' })).toHaveAttribute( - 'href', - `/workspace/review?boardId=${boardId}#proposal-${proposalId}`, - ) - - if (triageRunId) { - await expect(page.getByText(`Triage run: ${triageRunId}`)).toBeVisible() - } -}) +import { expect, test } from '@playwright/test' +import { registerAndAttachSession, type AuthResult } from './support/authSession' +import { createBoardWithColumn } from './support/boardHelpers' +import { + createCaptureItem, + listBoardCards, + waitForCardWithTitle, + waitForProposalCreated, +} from './support/captureFlow' + +let auth: AuthResult + +test.beforeEach(async ({ page, request }) => { + auth = await registerAndAttachSession(page, request, 'capture-loop') +}) + +test('capture triage should create proposal and apply card with provenance links', async ({ page, request }) => { + const seed = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}` + const boardId = await createBoardWithColumn(request, auth, seed, { + boardNamePrefix: 'Capture Loop', + description: 'capture triage e2e board', + columnNamePrefix: 'Inbox', + }) + const checklistTaskTitle = `Capture loop card ${seed}` + const captureText = `- [ ] ${checklistTaskTitle}` + + const createdCapture = await createCaptureItem(request, auth, boardId, captureText) + const captureId = createdCapture.id + + await page.goto('/workspace/inbox') + const captureRow = page.locator('[data-testid="inbox-item"]').filter({ hasText: checklistTaskTitle }).first() + await expect(captureRow).toBeVisible() + await captureRow.click() + + const triageButton = page.locator('.td-inbox-detail__actions button').filter({ hasText: 'Start Triage' }).first() + await expect(triageButton).toBeVisible() + await triageButton.click() + + const triagedCapture = await waitForProposalCreated(request, auth, captureId) + const proposalId = triagedCapture.provenance?.proposalId + const triageRunId = triagedCapture.provenance?.triageRunId + expect(proposalId).toBeTruthy() + const cardsAfterTriage = await listBoardCards(request, auth, boardId) + expect(cardsAfterTriage.length).toBe(0) + + await page.getByRole('button', { name: 'Refresh Detail' }).click() + const openProposalButton = page.getByRole('button', { name: 'Open in Review' }) + await expect(openProposalButton).toBeVisible() + await openProposalButton.click() + + await expect(page).toHaveURL(new RegExp(`/workspace/review\\?boardId=${boardId}#proposal-${proposalId}`)) + const proposalCard = page.locator(`#proposal-${proposalId}`) + await expect(proposalCard).toBeVisible() + + await proposalCard.getByRole('button', { name: 'Approve for board' }).click() + await expect(proposalCard.getByText('Approved, ready to apply')).toBeVisible() + const cardsAfterApprove = await listBoardCards(request, auth, boardId) + expect(cardsAfterApprove.length).toBe(0) + + page.once('dialog', (dialog) => dialog.accept()) + await proposalCard.getByRole('button', { name: 'Apply to board' }).click() + await expect(proposalCard).not.toBeVisible() + + const createdCard = await waitForCardWithTitle(request, auth, boardId, checklistTaskTitle) + + await page.goto(`/workspace/boards/${boardId}`) + const card = page.locator('[data-card-id]').filter({ hasText: createdCard.title }).first() + await expect(card).toBeVisible() + await card.getByRole('heading', { name: createdCard.title, exact: true }).click() + + await expect(page.getByRole('heading', { name: 'Edit Card' })).toBeVisible() + await expect(page.getByText('Capture Origin')).toBeVisible() + await expect(page.getByRole('link', { name: 'Open Capture' })).toHaveAttribute( + 'href', + `/workspace/inbox?boardId=${boardId}#capture-${captureId}`, + ) + await expect(page.getByRole('link', { name: 'Open Proposal' })).toHaveAttribute( + 'href', + `/workspace/review?boardId=${boardId}#proposal-${proposalId}`, + ) + + if (triageRunId) { + await expect(page.getByText(`Triage run: ${triageRunId}`)).toBeVisible() + } +}) diff --git a/frontend/taskdeck-web/tests/e2e/concurrency.spec.ts b/frontend/taskdeck-web/tests/e2e/concurrency.spec.ts index 5f795bfe7..49d6d4892 100644 --- a/frontend/taskdeck-web/tests/e2e/concurrency.spec.ts +++ b/frontend/taskdeck-web/tests/e2e/concurrency.spec.ts @@ -1,28 +1,28 @@ -import type { Page } from '@playwright/test' -import { expect, test } from '@playwright/test' -import { attachSessionToPage, registerAndAttachSession } from './support/authSession' - -async function gotoBoardsWorkspace(page: Page) { - await page.goto('/workspace/boards') - await expect(page.getByRole('button', { name: '+ New Board' })).toBeVisible() -} - -async function createBoard(page: Page, boardName: string) { - await gotoBoardsWorkspace(page) - await page.getByRole('button', { name: '+ New Board' }).click() - await page.getByPlaceholder('Board name').fill(boardName) - await page.getByRole('button', { name: 'Create', exact: true }).click() - await expect(page).toHaveURL(/\/workspace\/boards\/[a-f0-9-]+$/) - await expect(page.getByRole('heading', { name: boardName })).toBeVisible() -} - -function columnByName(page: Page, columnName: string) { - return page - .locator('[data-column-id]') - .filter({ has: page.getByRole('heading', { name: columnName, exact: true }) }) - .first() -} - +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { attachSessionToPage, registerAndAttachSession } from './support/authSession' + +async function gotoBoardsWorkspace(page: Page) { + await page.goto('/workspace/boards') + await expect(page.getByRole('button', { name: '+ New Board' })).toBeVisible() +} + +async function createBoard(page: Page, boardName: string) { + await gotoBoardsWorkspace(page) + await page.getByRole('button', { name: '+ New Board' }).click() + await page.getByPlaceholder('Board name').fill(boardName) + await page.getByRole('button', { name: 'Create', exact: true }).click() + await expect(page).toHaveURL(/\/workspace\/boards\/[a-f0-9-]+$/) + await expect(page.getByRole('heading', { name: boardName })).toBeVisible() +} + +function columnByName(page: Page, columnName: string) { + return page + .locator('[data-column-id]') + .filter({ has: page.getByRole('heading', { name: columnName, exact: true }) }) + .first() +} + function cardByTitle(page: Page, cardTitle: string) { return page.locator('[data-card-id]').filter({ hasText: cardTitle }).first() } @@ -31,88 +31,88 @@ function cardTitleByText(page: Page, cardTitle: string) { return cardByTitle(page, cardTitle).getByRole('heading', { name: cardTitle, exact: true }) } -async function addColumn(page: Page, columnName: string) { - await page.getByRole('button', { name: '+ Add Column' }).click() - await page.getByPlaceholder('Column name').fill(columnName) - await page.getByRole('button', { name: 'Create', exact: true }).click() - await expect(page.getByRole('heading', { name: columnName, exact: true })).toBeVisible() -} - -async function addCard(page: Page, columnName: string, cardTitle: string) { - const column = columnByName(page, columnName) - await column.getByRole('button', { name: 'Add Card' }).click() - await column.getByPlaceholder('Enter card title...').fill(cardTitle) - await column.getByRole('button', { name: 'Add', exact: true }).click() - await expect(cardByTitle(page, cardTitle)).toBeVisible() -} - -test('concurrent stale card edit should return conflict hint and preserve latest saved state', async ({ browser, page, request }) => { - const auth = await registerAndAttachSession(page, request, 'concurrency') - const boardName = `Concurrency Board ${Date.now()}` - const columnName = `Concurrency Column ${Date.now()}` - const initialTitle = `Concurrency Card ${Date.now()}` - const secondaryTitle = `${initialTitle} Secondary` - const staleTitle = `${initialTitle} Stale` - - await createBoard(page, boardName) - await addColumn(page, columnName) - await addCard(page, columnName, initialTitle) - const boardUrl = page.url() - - const secondaryContext = await browser.newContext() - const secondaryPage = await secondaryContext.newPage() - await attachSessionToPage(secondaryPage, auth) - await secondaryPage.goto(boardUrl) - - try { - await expect(secondaryPage.getByRole('heading', { name: boardName })).toBeVisible() - await expect(cardByTitle(secondaryPage, initialTitle)).toBeVisible() - +async function addColumn(page: Page, columnName: string) { + await page.getByRole('button', { name: '+ Add Column' }).click() + await page.getByPlaceholder('Column name').fill(columnName) + await page.getByRole('button', { name: 'Create', exact: true }).click() + await expect(page.getByRole('heading', { name: columnName, exact: true })).toBeVisible() +} + +async function addCard(page: Page, columnName: string, cardTitle: string) { + const column = columnByName(page, columnName) + await column.getByRole('button', { name: 'Add Card' }).click() + await column.getByPlaceholder('Enter card title...').fill(cardTitle) + await column.getByRole('button', { name: 'Add', exact: true }).click() + await expect(cardByTitle(page, cardTitle)).toBeVisible() +} + +test('concurrent stale card edit should return conflict hint and preserve latest saved state', async ({ browser, page, request }) => { + const auth = await registerAndAttachSession(page, request, 'concurrency') + const boardName = `Concurrency Board ${Date.now()}` + const columnName = `Concurrency Column ${Date.now()}` + const initialTitle = `Concurrency Card ${Date.now()}` + const secondaryTitle = `${initialTitle} Secondary` + const staleTitle = `${initialTitle} Stale` + + await createBoard(page, boardName) + await addColumn(page, columnName) + await addCard(page, columnName, initialTitle) + const boardUrl = page.url() + + const secondaryContext = await browser.newContext() + const secondaryPage = await secondaryContext.newPage() + await attachSessionToPage(secondaryPage, auth) + await secondaryPage.goto(boardUrl) + + try { + await expect(secondaryPage.getByRole('heading', { name: boardName })).toBeVisible() + await expect(cardByTitle(secondaryPage, initialTitle)).toBeVisible() + await cardTitleByText(page, initialTitle).click() - await expect(page.getByRole('heading', { name: 'Edit Card' })).toBeVisible() - await page.locator('#card-title').fill(staleTitle) - + await expect(page.getByRole('heading', { name: 'Edit Card' })).toBeVisible() + await page.locator('#card-title').fill(staleTitle) + await cardTitleByText(secondaryPage, initialTitle).click() - await expect(secondaryPage.getByRole('heading', { name: 'Edit Card' })).toBeVisible() - await secondaryPage.locator('#card-title').fill(secondaryTitle) - await secondaryPage.getByRole('button', { name: 'Save Changes' }).click() - await expect(secondaryPage.getByRole('heading', { name: 'Edit Card' })).toHaveCount(0) - await expect(cardByTitle(secondaryPage, secondaryTitle)).toBeVisible({ timeout: 15000 }) - - await page.getByRole('button', { name: 'Save Changes' }).click() - await expect(page.getByText('updated by another session', { exact: false }).first()).toBeVisible({ timeout: 15000 }) - - await expect(cardByTitle(page, secondaryTitle)).toBeVisible({ timeout: 20000 }) - await expect(cardByTitle(page, staleTitle)).toHaveCount(0) - - await secondaryPage.reload() - await expect(cardByTitle(secondaryPage, secondaryTitle)).toBeVisible({ timeout: 20000 }) - await expect(cardByTitle(secondaryPage, staleTitle)).toHaveCount(0) - } finally { - await secondaryContext.close() - } -}) - -test('secondary session should receive new cards without manual refresh', async ({ browser, page, request }) => { - const auth = await registerAndAttachSession(page, request, 'concurrency-realtime') - const boardName = `Concurrency Realtime Board ${Date.now()}` - const columnName = `Concurrency Realtime Column ${Date.now()}` - const cardTitle = `Realtime Card ${Date.now()}` - - await createBoard(page, boardName) - await addColumn(page, columnName) - const boardUrl = page.url() - - const secondaryContext = await browser.newContext() - const secondaryPage = await secondaryContext.newPage() - await attachSessionToPage(secondaryPage, auth) - await secondaryPage.goto(boardUrl) - - try { - await expect(secondaryPage.getByRole('heading', { name: boardName })).toBeVisible() - await addCard(page, columnName, cardTitle) - await expect(cardByTitle(secondaryPage, cardTitle)).toBeVisible({ timeout: 25000 }) - } finally { - await secondaryContext.close() - } -}) + await expect(secondaryPage.getByRole('heading', { name: 'Edit Card' })).toBeVisible() + await secondaryPage.locator('#card-title').fill(secondaryTitle) + await secondaryPage.getByRole('button', { name: 'Save Changes' }).click() + await expect(secondaryPage.getByRole('heading', { name: 'Edit Card' })).toHaveCount(0) + await expect(cardByTitle(secondaryPage, secondaryTitle)).toBeVisible({ timeout: 15000 }) + + await page.getByRole('button', { name: 'Save Changes' }).click() + await expect(page.getByText('updated by another session', { exact: false }).first()).toBeVisible({ timeout: 15000 }) + + await expect(cardByTitle(page, secondaryTitle)).toBeVisible({ timeout: 20000 }) + await expect(cardByTitle(page, staleTitle)).toHaveCount(0) + + await secondaryPage.reload() + await expect(cardByTitle(secondaryPage, secondaryTitle)).toBeVisible({ timeout: 20000 }) + await expect(cardByTitle(secondaryPage, staleTitle)).toHaveCount(0) + } finally { + await secondaryContext.close() + } +}) + +test('secondary session should receive new cards without manual refresh', async ({ browser, page, request }) => { + const auth = await registerAndAttachSession(page, request, 'concurrency-realtime') + const boardName = `Concurrency Realtime Board ${Date.now()}` + const columnName = `Concurrency Realtime Column ${Date.now()}` + const cardTitle = `Realtime Card ${Date.now()}` + + await createBoard(page, boardName) + await addColumn(page, columnName) + const boardUrl = page.url() + + const secondaryContext = await browser.newContext() + const secondaryPage = await secondaryContext.newPage() + await attachSessionToPage(secondaryPage, auth) + await secondaryPage.goto(boardUrl) + + try { + await expect(secondaryPage.getByRole('heading', { name: boardName })).toBeVisible() + await addCard(page, columnName, cardTitle) + await expect(cardByTitle(secondaryPage, cardTitle)).toBeVisible({ timeout: 25000 }) + } finally { + await secondaryContext.close() + } +}) diff --git a/frontend/taskdeck-web/tests/e2e/stakeholder-demo.spec.ts b/frontend/taskdeck-web/tests/e2e/stakeholder-demo.spec.ts index d1d434f3d..ce07e5e96 100644 --- a/frontend/taskdeck-web/tests/e2e/stakeholder-demo.spec.ts +++ b/frontend/taskdeck-web/tests/e2e/stakeholder-demo.spec.ts @@ -9,67 +9,67 @@ import { parseTrueishEnv } from '../../scripts/demo-shared.mjs' import { resolveScenarioSelectedBoardName } from '../../scripts/demo-scenario-defaults.mjs' import type { AuthResult } from './support/authSession' import { attachSessionToPage } from './support/authSession' - + const DEFAULT_SETUP_TIMEOUT_MS = 120_000 const DEFAULT_SETUP_SCRIPT_MAX_BUFFER_BYTES = 64 * 1024 * 1024 const MIN_SETUP_SCRIPT_MAX_BUFFER_BYTES = 1024 * 1024 const DEFAULT_PLAYWRIGHT_TEST_TIMEOUT_MS = 180_000 const REQUIRED_WALKTHROUGH_FEATURE_FLAGS = ['Activity & Audit Views', 'Ops Console'] - -function isAppRoot(candidateDir: string): boolean { - const hasPackageJson = existsSync(path.join(candidateDir, 'package.json')) - const hasDemoSeed = existsSync(path.join(candidateDir, 'scripts', 'demo-seed.mjs')) - const hasDemoRun = existsSync(path.join(candidateDir, 'scripts', 'demo-run.mjs')) - return hasPackageJson && hasDemoSeed && hasDemoRun -} - -function parseSetupTimeoutMs(value: string | undefined): number { - const parsed = Number(value) - if (Number.isFinite(parsed) && parsed > 0) { - return Math.floor(parsed) - } - return DEFAULT_SETUP_TIMEOUT_MS -} - -function parseSetupScriptMaxBufferBytes(value: string | undefined): number { - const parsed = Number(value) - if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < MIN_SETUP_SCRIPT_MAX_BUFFER_BYTES) { - return DEFAULT_SETUP_SCRIPT_MAX_BUFFER_BYTES - } - return parsed -} - -function parseOptionalPositiveInteger(value: string | undefined, fallback: number): number { - const parsed = Number(value) - if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) { - return fallback - } - return parsed -} - -function parseOptionalNonNegativeInteger(value: string | undefined, fallback: number): number { - const parsed = Number(value) - if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) { - return fallback - } - return parsed -} - + +function isAppRoot(candidateDir: string): boolean { + const hasPackageJson = existsSync(path.join(candidateDir, 'package.json')) + const hasDemoSeed = existsSync(path.join(candidateDir, 'scripts', 'demo-seed.mjs')) + const hasDemoRun = existsSync(path.join(candidateDir, 'scripts', 'demo-run.mjs')) + return hasPackageJson && hasDemoSeed && hasDemoRun +} + +function parseSetupTimeoutMs(value: string | undefined): number { + const parsed = Number(value) + if (Number.isFinite(parsed) && parsed > 0) { + return Math.floor(parsed) + } + return DEFAULT_SETUP_TIMEOUT_MS +} + +function parseSetupScriptMaxBufferBytes(value: string | undefined): number { + const parsed = Number(value) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < MIN_SETUP_SCRIPT_MAX_BUFFER_BYTES) { + return DEFAULT_SETUP_SCRIPT_MAX_BUFFER_BYTES + } + return parsed +} + +function parseOptionalPositiveInteger(value: string | undefined, fallback: number): number { + const parsed = Number(value) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) { + return fallback + } + return parsed +} + +function parseOptionalNonNegativeInteger(value: string | undefined, fallback: number): number { + const parsed = Number(value) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) { + return fallback + } + return parsed +} + function computeStakeholderDemoTimeoutMs(): number { const setupTimeoutMs = parseSetupTimeoutMs(process.env.TASKDECK_DEMO_SETUP_TIMEOUT_MS) const scenarioId = (process.env.TASKDECK_DEMO_SCENARIO || 'client-onboarding').trim() const skipSeed = parseTrueishEnv(process.env.TASKDECK_DEMO_SKIP_SEED) const autopilotTurns = parseOptionalPositiveInteger(process.env.TASKDECK_DEMO_AUTOPILOT_TURNS, 0) const snapshotPath = (process.env.TASKDECK_DEMO_SNAPSHOT_PATH || '').trim() - - let setupSteps = 0 - if (!skipSeed) setupSteps += 1 - if (scenarioId) setupSteps += 1 - if (autopilotTurns > 0) setupSteps += 1 - if (snapshotPath) setupSteps += 1 - - const walkthroughBudgetMs = 120_000 - const setupBudgetMs = setupSteps * setupTimeoutMs + + let setupSteps = 0 + if (!skipSeed) setupSteps += 1 + if (scenarioId) setupSteps += 1 + if (autopilotTurns > 0) setupSteps += 1 + if (snapshotPath) setupSteps += 1 + + const walkthroughBudgetMs = 120_000 + const setupBudgetMs = setupSteps * setupTimeoutMs return Math.max(DEFAULT_PLAYWRIGHT_TEST_TIMEOUT_MS, setupBudgetMs + walkthroughBudgetMs) } @@ -85,124 +85,124 @@ async function ensureWalkthroughFeatureFlagsEnabled(page: Page) { } } } - -function resolveAppRoot(startDir: string): string { - const cwd = process.cwd() - if (isAppRoot(cwd)) { - return cwd - } - - const fromSpecDir = path.resolve(startDir, '..', '..') - if (isAppRoot(fromSpecDir)) { - return fromSpecDir - } - - let current = startDir - - while (current !== path.dirname(current)) { - if (isAppRoot(current)) { - return current - } - - current = path.dirname(current) - } - - if (isAppRoot(current)) { - return current - } - - throw new Error('Unable to resolve frontend/taskdeck-web app root for stakeholder demo setup.') -} - -function runSetupScript({ - appRoot, - scriptArgs, - env, - timeoutMs, - label, - logPath, - echoOutput, -}: { - appRoot: string - scriptArgs: string[] - env: NodeJS.ProcessEnv - timeoutMs: number - label: string - logPath: string | null - echoOutput: boolean -}): void { - const useStreamingConsoleOutput = echoOutput && !logPath - const result = useStreamingConsoleOutput - ? spawnSync(process.execPath, scriptArgs, { - cwd: appRoot, - env, - stdio: 'inherit', - timeout: timeoutMs, - killSignal: 'SIGTERM', - }) - : spawnSync(process.execPath, scriptArgs, { - cwd: appRoot, - env, - encoding: 'utf8', - maxBuffer: parseSetupScriptMaxBufferBytes(env.TASKDECK_DEMO_SETUP_MAX_BUFFER_BYTES), - timeout: timeoutMs, - killSignal: 'SIGTERM', - }) - - const combined = useStreamingConsoleOutput ? '' : `${result.stdout || ''}${result.stderr || ''}` - if (logPath) { - writeFileSync(logPath, combined, 'utf8') - } - - if (!useStreamingConsoleOutput && echoOutput && combined.trim()) { - process.stdout.write(combined) - } - - if (result.error) { - const error = result.error as NodeJS.ErrnoException - if (error.code === 'ETIMEDOUT') { - throw new Error( - `Timed out after ${timeoutMs}ms running setup command: node ${scriptArgs.join(' ')} (cwd=${appRoot})`, - ) - } - - const command = `node ${scriptArgs.join(' ')}` - const statusInfo = `status=${String(result.status ?? 'null')}, signal=${String(result.signal ?? 'null')}` - const messageLines = [ - `Demo bootstrap failed at ${label} due to a spawn error.`, - `Command: ${command}`, - `CWD: ${appRoot}`, - `Result: ${statusInfo}`, - `Underlying error: ${error.message}${error.code ? ` (code=${error.code})` : ''}`, - ] - if (combined.trim()) { - messageLines.push('') - messageLines.push(combined) - } - throw new Error(messageLines.join('\n')) - } - - if ((result.status ?? 1) !== 0) { - throw new Error(`Demo bootstrap failed at ${label} (exit=${result.status}).\n\n${combined}`) - } -} - + +function resolveAppRoot(startDir: string): string { + const cwd = process.cwd() + if (isAppRoot(cwd)) { + return cwd + } + + const fromSpecDir = path.resolve(startDir, '..', '..') + if (isAppRoot(fromSpecDir)) { + return fromSpecDir + } + + let current = startDir + + while (current !== path.dirname(current)) { + if (isAppRoot(current)) { + return current + } + + current = path.dirname(current) + } + + if (isAppRoot(current)) { + return current + } + + throw new Error('Unable to resolve frontend/taskdeck-web app root for stakeholder demo setup.') +} + +function runSetupScript({ + appRoot, + scriptArgs, + env, + timeoutMs, + label, + logPath, + echoOutput, +}: { + appRoot: string + scriptArgs: string[] + env: NodeJS.ProcessEnv + timeoutMs: number + label: string + logPath: string | null + echoOutput: boolean +}): void { + const useStreamingConsoleOutput = echoOutput && !logPath + const result = useStreamingConsoleOutput + ? spawnSync(process.execPath, scriptArgs, { + cwd: appRoot, + env, + stdio: 'inherit', + timeout: timeoutMs, + killSignal: 'SIGTERM', + }) + : spawnSync(process.execPath, scriptArgs, { + cwd: appRoot, + env, + encoding: 'utf8', + maxBuffer: parseSetupScriptMaxBufferBytes(env.TASKDECK_DEMO_SETUP_MAX_BUFFER_BYTES), + timeout: timeoutMs, + killSignal: 'SIGTERM', + }) + + const combined = useStreamingConsoleOutput ? '' : `${result.stdout || ''}${result.stderr || ''}` + if (logPath) { + writeFileSync(logPath, combined, 'utf8') + } + + if (!useStreamingConsoleOutput && echoOutput && combined.trim()) { + process.stdout.write(combined) + } + + if (result.error) { + const error = result.error as NodeJS.ErrnoException + if (error.code === 'ETIMEDOUT') { + throw new Error( + `Timed out after ${timeoutMs}ms running setup command: node ${scriptArgs.join(' ')} (cwd=${appRoot})`, + ) + } + + const command = `node ${scriptArgs.join(' ')}` + const statusInfo = `status=${String(result.status ?? 'null')}, signal=${String(result.signal ?? 'null')}` + const messageLines = [ + `Demo bootstrap failed at ${label} due to a spawn error.`, + `Command: ${command}`, + `CWD: ${appRoot}`, + `Result: ${statusInfo}`, + `Underlying error: ${error.message}${error.code ? ` (code=${error.code})` : ''}`, + ] + if (combined.trim()) { + messageLines.push('') + messageLines.push(combined) + } + throw new Error(messageLines.join('\n')) + } + + if ((result.status ?? 1) !== 0) { + throw new Error(`Demo bootstrap failed at ${label} (exit=${result.status}).\n\n${combined}`) + } +} + /** * Stakeholder demo recorder. - * - * Intentionally opt-in so default CI/e2e runs are unaffected. - * - * PowerShell: - * $env:TASKDECK_RUN_DEMO='1' - * npx playwright test tests/e2e/stakeholder-demo.spec.ts --headed - */ - -test.use({ - video: 'on', - trace: 'on', - screenshot: 'on', -}) - + * + * Intentionally opt-in so default CI/e2e runs are unaffected. + * + * PowerShell: + * $env:TASKDECK_RUN_DEMO='1' + * npx playwright test tests/e2e/stakeholder-demo.spec.ts --headed + */ + +test.use({ + video: 'on', + trace: 'on', + screenshot: 'on', +}) + test.describe('Stakeholder demo recorder', () => { test.setTimeout(computeStakeholderDemoTimeoutMs()) test.skip(!parseTrueishEnv(process.env.TASKDECK_RUN_DEMO), 'Set TASKDECK_RUN_DEMO=1 to run this opt-in spec.') @@ -211,14 +211,14 @@ test.describe('Stakeholder demo recorder', () => { const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const appRoot = resolveAppRoot(__dirname) - const setupTimeoutMs = parseSetupTimeoutMs(process.env.TASKDECK_DEMO_SETUP_TIMEOUT_MS) - - const apiBaseUrl = process.env.TASKDECK_E2E_API_BASE_URL || 'http://localhost:5000/api' - const uiBaseUrl = - process.env.TASKDECK_E2E_FRONTEND_BASE_URL || - process.env.TASKDECK_UI_BASE_URL || - 'http://localhost:5173' - + const setupTimeoutMs = parseSetupTimeoutMs(process.env.TASKDECK_DEMO_SETUP_TIMEOUT_MS) + + const apiBaseUrl = process.env.TASKDECK_E2E_API_BASE_URL || 'http://localhost:5000/api' + const uiBaseUrl = + process.env.TASKDECK_E2E_FRONTEND_BASE_URL || + process.env.TASKDECK_UI_BASE_URL || + 'http://localhost:5173' + const isDirector = parseTrueishEnv(process.env.TASKDECK_DEMO_DIRECTOR) const artifactDir = (process.env.TASKDECK_DEMO_ARTIFACT_DIR || '').trim() || null const logsDir = artifactDir ? path.join(artifactDir, 'logs') : null @@ -234,42 +234,42 @@ test.describe('Stakeholder demo recorder', () => { scenarioIdOrPath: scenarioId, explicitBoardName: walkthroughBoardName || autopilotBoardOverride, }) - const autopilotLoop = (process.env.TASKDECK_DEMO_AUTOPILOT_LOOP || 'mixed').trim() || 'mixed' - const autopilotBrain = (process.env.TASKDECK_DEMO_AUTOPILOT_BRAIN || 'heuristic').trim() || 'heuristic' - const autopilotIntervalMs = parseOptionalNonNegativeInteger(process.env.TASKDECK_DEMO_AUTOPILOT_INTERVAL_MS, 700) - const autopilotSeed = (process.env.TASKDECK_DEMO_AUTOPILOT_RNG_SEED || '').trim() || null - - const snapshotPath = (process.env.TASKDECK_DEMO_SNAPSHOT_PATH || '').trim() || null - - if (logsDir) { - mkdirSync(logsDir, { recursive: true }) - } - - const setupEnv = { - ...process.env, - TASKDECK_API_BASE_URL: apiBaseUrl, - TASKDECK_API_BASE: apiBaseUrl, - TASKDECK_UI_BASE: uiBaseUrl, - TASKDECK_UI_BASE_URL: uiBaseUrl, - TASKDECK_E2E_API_BASE_URL: apiBaseUrl, - TASKDECK_E2E_FRONTEND_BASE_URL: uiBaseUrl, - } - - const runScript = (label: string, scriptArgs: string[], logFileName: string | null) => { - const logPath = logFileName && logsDir ? path.join(logsDir, logFileName) : null - runSetupScript({ - appRoot, - scriptArgs, - env: setupEnv, - timeoutMs: setupTimeoutMs, - label, - logPath, - echoOutput: !isDirector, - }) - } - - if (!skipSeed) { - runScript('demo-seed', ['scripts/demo-seed.mjs'], isDirector ? 'seed.log' : null) + const autopilotLoop = (process.env.TASKDECK_DEMO_AUTOPILOT_LOOP || 'mixed').trim() || 'mixed' + const autopilotBrain = (process.env.TASKDECK_DEMO_AUTOPILOT_BRAIN || 'heuristic').trim() || 'heuristic' + const autopilotIntervalMs = parseOptionalNonNegativeInteger(process.env.TASKDECK_DEMO_AUTOPILOT_INTERVAL_MS, 700) + const autopilotSeed = (process.env.TASKDECK_DEMO_AUTOPILOT_RNG_SEED || '').trim() || null + + const snapshotPath = (process.env.TASKDECK_DEMO_SNAPSHOT_PATH || '').trim() || null + + if (logsDir) { + mkdirSync(logsDir, { recursive: true }) + } + + const setupEnv = { + ...process.env, + TASKDECK_API_BASE_URL: apiBaseUrl, + TASKDECK_API_BASE: apiBaseUrl, + TASKDECK_UI_BASE: uiBaseUrl, + TASKDECK_UI_BASE_URL: uiBaseUrl, + TASKDECK_E2E_API_BASE_URL: apiBaseUrl, + TASKDECK_E2E_FRONTEND_BASE_URL: uiBaseUrl, + } + + const runScript = (label: string, scriptArgs: string[], logFileName: string | null) => { + const logPath = logFileName && logsDir ? path.join(logsDir, logFileName) : null + runSetupScript({ + appRoot, + scriptArgs, + env: setupEnv, + timeoutMs: setupTimeoutMs, + label, + logPath, + echoOutput: !isDirector, + }) + } + + if (!skipSeed) { + runScript('demo-seed', ['scripts/demo-seed.mjs'], isDirector ? 'seed.log' : null) } if (scenarioId) { @@ -277,35 +277,35 @@ test.describe('Stakeholder demo recorder', () => { if (skipLlm) { runArgs.push('--skip-llm') } - runScript('demo-run', runArgs, isDirector ? 'scenario.log' : null) - } - - if (autopilotTurns > 0) { - const autopilotArgs = [ - 'scripts/demo-autopilot.mjs', - '--board', - autopilotBoardName, - '--turns', - String(autopilotTurns), - '--interval-ms', - String(autopilotIntervalMs), - '--loop', - autopilotLoop, - '--brain', - autopilotBrain, - ] - if (autopilotSeed) { - autopilotArgs.push('--rng-seed', autopilotSeed) - } - - runScript('demo-autopilot', autopilotArgs, isDirector ? 'autopilot.log' : null) - } - - if (snapshotPath) { - runScript('demo-snapshot', ['scripts/demo-snapshot.mjs', '--out', snapshotPath], isDirector ? 'snapshot.log' : null) - } - }) - + runScript('demo-run', runArgs, isDirector ? 'scenario.log' : null) + } + + if (autopilotTurns > 0) { + const autopilotArgs = [ + 'scripts/demo-autopilot.mjs', + '--board', + autopilotBoardName, + '--turns', + String(autopilotTurns), + '--interval-ms', + String(autopilotIntervalMs), + '--loop', + autopilotLoop, + '--brain', + autopilotBrain, + ] + if (autopilotSeed) { + autopilotArgs.push('--rng-seed', autopilotSeed) + } + + runScript('demo-autopilot', autopilotArgs, isDirector ? 'autopilot.log' : null) + } + + if (snapshotPath) { + runScript('demo-snapshot', ['scripts/demo-snapshot.mjs', '--out', snapshotPath], isDirector ? 'snapshot.log' : null) + } + }) + test('captures guided stakeholder clickthrough', async ({ page, request }, testInfo) => { const apiBaseUrl = process.env.TASKDECK_E2E_API_BASE_URL || 'http://localhost:5000/api' const demoUsername = process.env.TASKDECK_DEMO_USERNAME || 'demo' @@ -321,9 +321,9 @@ test.describe('Stakeholder demo recorder', () => { const loginResponse = await request.post(`${apiBaseUrl}/auth/login`, { data: { usernameOrEmail: demoUsername, - password: demoPassword, - }, - }) + password: demoPassword, + }, + }) expect(loginResponse.ok()).toBeTruthy() const auth = (await loginResponse.json()) as AuthResult @@ -347,11 +347,11 @@ test.describe('Stakeholder demo recorder', () => { await expect(cardEditorHeading).toBeVisible() await page.screenshot({ path: testInfo.outputPath('03-card-modal.png'), fullPage: true }) await page.keyboard.press('Escape') - + await page.getByRole('link', { name: 'Inbox' }).click() await expect(page.getByRole('heading', { name: 'Inbox', level: 1 })).toBeVisible() - await page.screenshot({ path: testInfo.outputPath('04-inbox.png'), fullPage: true }) - + await page.screenshot({ path: testInfo.outputPath('04-inbox.png'), fullPage: true }) + await page.getByRole('link', { name: 'Review' }).click() await expect(page.getByRole('heading', { name: 'Review', exact: true, level: 1 })).toBeVisible() await page.screenshot({ path: testInfo.outputPath('05-automations-proposals.png'), fullPage: true }) @@ -360,27 +360,27 @@ test.describe('Stakeholder demo recorder', () => { await expect(page.getByRole('heading', { name: 'Automation Queue', exact: true, level: 1 })).toBeVisible() await expect(page.getByRole('button', { name: /New Request/ })).toBeVisible() await page.screenshot({ path: testInfo.outputPath('06-automations-queue.png'), fullPage: true }) - - await page.getByRole('button', { name: /New Request/ }).click() - const requestComposer = page.locator('textarea.td-textarea') - await expect(requestComposer).toBeVisible() - await requestComposer.fill('list pending proposals') - const submitRequestButton = page.getByRole('button', { name: 'Submit Request' }) - await expect(submitRequestButton).toBeVisible() - await submitRequestButton.click() - await expect(requestComposer).toBeHidden() - await page.screenshot({ path: testInfo.outputPath('07-queue-submitted.png'), fullPage: true }) - + + await page.getByRole('button', { name: /New Request/ }).click() + const requestComposer = page.locator('textarea.td-textarea') + await expect(requestComposer).toBeVisible() + await requestComposer.fill('list pending proposals') + const submitRequestButton = page.getByRole('button', { name: 'Submit Request' }) + await expect(submitRequestButton).toBeVisible() + await submitRequestButton.click() + await expect(requestComposer).toBeHidden() + await page.screenshot({ path: testInfo.outputPath('07-queue-submitted.png'), fullPage: true }) + await page.getByRole('link', { name: 'Ops' }).click() await expect(page.getByRole('heading', { name: 'Ops Console', level: 1 })).toBeVisible() - await page.screenshot({ path: testInfo.outputPath('08-ops.png'), fullPage: true }) - + await page.screenshot({ path: testInfo.outputPath('08-ops.png'), fullPage: true }) + await page.getByRole('link', { name: 'Activity' }).click() await expect(page.getByRole('heading', { name: 'Activity', level: 1 })).toBeVisible() - await page.screenshot({ path: testInfo.outputPath('09-activity.png'), fullPage: true }) - + await page.screenshot({ path: testInfo.outputPath('09-activity.png'), fullPage: true }) + await page.getByRole('link', { name: 'Notifications' }).click() await expect(page.getByRole('heading', { name: 'Notifications', level: 1 })).toBeVisible() - await page.screenshot({ path: testInfo.outputPath('10-notifications.png'), fullPage: true }) - }) -}) + await page.screenshot({ path: testInfo.outputPath('10-notifications.png'), fullPage: true }) + }) +})