diff --git a/.github/workflows/cd-staging-gate.yml b/.github/workflows/cd-staging-gate.yml index 81771e8182..7ab0d2e630 100644 --- a/.github/workflows/cd-staging-gate.yml +++ b/.github/workflows/cd-staging-gate.yml @@ -50,6 +50,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Resolve image tag id: resolve-tag @@ -127,6 +129,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Build container images run: | diff --git a/.github/workflows/ci-extended.yml b/.github/workflows/ci-extended.yml index eb63ee8482..0497fe724e 100644 --- a/.github/workflows/ci-extended.yml +++ b/.github/workflows/ci-extended.yml @@ -179,6 +179,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Run dependency review uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 8a57ba579a..f20d06a715 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -37,6 +37,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/mutation-testing.yml b/.github/workflows/mutation-testing.yml index aa1cab02d8..60cef0029e 100644 --- a/.github/workflows/mutation-testing.yml +++ b/.github/workflows/mutation-testing.yml @@ -41,6 +41,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 @@ -78,6 +80,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/nightly-quality.yml b/.github/workflows/nightly-quality.yml index 5a7f718c33..87bc1eb7f8 100644 --- a/.github/workflows/nightly-quality.yml +++ b/.github/workflows/nightly-quality.yml @@ -28,6 +28,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 @@ -74,6 +76,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/pages-frontend.yml b/.github/workflows/pages-frontend.yml index 1eba10d83e..996e1739f3 100644 --- a/.github/workflows/pages-frontend.yml +++ b/.github/workflows/pages-frontend.yml @@ -18,8 +18,6 @@ on: permissions: contents: read - pages: write - id-token: write concurrency: group: pages @@ -29,9 +27,14 @@ jobs: build: name: Build Frontend runs-on: ubuntu-latest + permissions: + contents: read + pages: read # configure-pages reads the existing site; enablement defaults to false. steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -78,6 +81,9 @@ jobs: name: Deploy to GitHub Pages needs: build runs-on: ubuntu-latest + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/release-security.yml b/.github/workflows/release-security.yml index fe4aeeeec0..e8701fda48 100644 --- a/.github/workflows/release-security.yml +++ b/.github/workflows/release-security.yml @@ -40,6 +40,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/reusable-api-integration.yml b/.github/workflows/reusable-api-integration.yml index 836ef920fc..b4ee0a683b 100644 --- a/.github/workflows/reusable-api-integration.yml +++ b/.github/workflows/reusable-api-integration.yml @@ -28,6 +28,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/reusable-backend-architecture.yml b/.github/workflows/reusable-backend-architecture.yml index ce4a7c0689..86b773dbe2 100644 --- a/.github/workflows/reusable-backend-architecture.yml +++ b/.github/workflows/reusable-backend-architecture.yml @@ -22,6 +22,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/reusable-backend-solution.yml b/.github/workflows/reusable-backend-solution.yml index e1566d24c3..a1513108e8 100644 --- a/.github/workflows/reusable-backend-solution.yml +++ b/.github/workflows/reusable-backend-solution.yml @@ -22,6 +22,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/reusable-backend-unit.yml b/.github/workflows/reusable-backend-unit.yml index 4e82374891..78a9cdc9d1 100644 --- a/.github/workflows/reusable-backend-unit.yml +++ b/.github/workflows/reusable-backend-unit.yml @@ -28,6 +28,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/reusable-container-images.yml b/.github/workflows/reusable-container-images.yml index 369c62ed96..fcfcb9bfc3 100644 --- a/.github/workflows/reusable-container-images.yml +++ b/.github/workflows/reusable-container-images.yml @@ -32,6 +32,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Validate reverse-proxy routing contract shell: pwsh diff --git a/.github/workflows/reusable-container-integration.yml b/.github/workflows/reusable-container-integration.yml index d48b8d8b6f..c76334e805 100644 --- a/.github/workflows/reusable-container-integration.yml +++ b/.github/workflows/reusable-container-integration.yml @@ -23,6 +23,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/reusable-demo-director-smoke.yml b/.github/workflows/reusable-demo-director-smoke.yml index fa40cf9ffb..50d3163c40 100644 --- a/.github/workflows/reusable-demo-director-smoke.yml +++ b/.github/workflows/reusable-demo-director-smoke.yml @@ -28,6 +28,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/reusable-dependency-security-signals.yml b/.github/workflows/reusable-dependency-security-signals.yml index 36eae28dfc..69500a9662 100644 --- a/.github/workflows/reusable-dependency-security-signals.yml +++ b/.github/workflows/reusable-dependency-security-signals.yml @@ -53,6 +53,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/reusable-developer-portal.yml b/.github/workflows/reusable-developer-portal.yml index 419b3b5aa0..39058944e2 100644 --- a/.github/workflows/reusable-developer-portal.yml +++ b/.github/workflows/reusable-developer-portal.yml @@ -28,6 +28,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/reusable-docs-governance.yml b/.github/workflows/reusable-docs-governance.yml index fc3e8fe4bd..a1cf972371 100644 --- a/.github/workflows/reusable-docs-governance.yml +++ b/.github/workflows/reusable-docs-governance.yml @@ -19,6 +19,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Validate detached-first worktree helper shell: powershell @@ -38,6 +40,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/reusable-e2e-cross-browser.yml b/.github/workflows/reusable-e2e-cross-browser.yml index f45db1f56d..dc74a9bd11 100644 --- a/.github/workflows/reusable-e2e-cross-browser.yml +++ b/.github/workflows/reusable-e2e-cross-browser.yml @@ -42,6 +42,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/reusable-e2e-smoke.yml b/.github/workflows/reusable-e2e-smoke.yml index efc706aa5d..21062918be 100644 --- a/.github/workflows/reusable-e2e-smoke.yml +++ b/.github/workflows/reusable-e2e-smoke.yml @@ -28,6 +28,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/reusable-frontend-unit.yml b/.github/workflows/reusable-frontend-unit.yml index 828808c6e1..c7d027675a 100644 --- a/.github/workflows/reusable-frontend-unit.yml +++ b/.github/workflows/reusable-frontend-unit.yml @@ -26,6 +26,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/reusable-gitleaks.yml b/.github/workflows/reusable-gitleaks.yml index 600582fd79..f9098d9de4 100644 --- a/.github/workflows/reusable-gitleaks.yml +++ b/.github/workflows/reusable-gitleaks.yml @@ -61,6 +61,7 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false # Full history needed for both PR diff and full-history scanning fetch-depth: 0 diff --git a/.github/workflows/reusable-load-concurrency-harness.yml b/.github/workflows/reusable-load-concurrency-harness.yml index 1e1e793170..ff402a2da9 100644 --- a/.github/workflows/reusable-load-concurrency-harness.yml +++ b/.github/workflows/reusable-load-concurrency-harness.yml @@ -43,6 +43,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/reusable-migration-validation.yml b/.github/workflows/reusable-migration-validation.yml index 41f021ee30..b6ea9adb6e 100644 --- a/.github/workflows/reusable-migration-validation.yml +++ b/.github/workflows/reusable-migration-validation.yml @@ -22,6 +22,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/reusable-openapi-guardrail.yml b/.github/workflows/reusable-openapi-guardrail.yml index 699e3554f1..799397e6f1 100644 --- a/.github/workflows/reusable-openapi-guardrail.yml +++ b/.github/workflows/reusable-openapi-guardrail.yml @@ -23,6 +23,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/reusable-paper-color-audit.yml b/.github/workflows/reusable-paper-color-audit.yml index aaf55da4fe..5332be88dc 100644 --- a/.github/workflows/reusable-paper-color-audit.yml +++ b/.github/workflows/reusable-paper-color-audit.yml @@ -42,6 +42,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Test Paper color audit scanner run: node --test scripts/ci/check-paper-color-components.test.mjs diff --git a/.github/workflows/reusable-performance-regression-gate.yml b/.github/workflows/reusable-performance-regression-gate.yml index 656898fe23..2d2d318c9f 100644 --- a/.github/workflows/reusable-performance-regression-gate.yml +++ b/.github/workflows/reusable-performance-regression-gate.yml @@ -87,6 +87,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/reusable-sast-scanning.yml b/.github/workflows/reusable-sast-scanning.yml index 4f3898e74f..6e75e17a33 100644 --- a/.github/workflows/reusable-sast-scanning.yml +++ b/.github/workflows/reusable-sast-scanning.yml @@ -47,6 +47,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/.github/workflows/reusable-sbom-provenance.yml b/.github/workflows/reusable-sbom-provenance.yml index f85e48a341..4d64797dc4 100644 --- a/.github/workflows/reusable-sbom-provenance.yml +++ b/.github/workflows/reusable-sbom-provenance.yml @@ -48,6 +48,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/reusable-visual-regression.yml b/.github/workflows/reusable-visual-regression.yml index 6ec570d19e..cdb6340f6d 100644 --- a/.github/workflows/reusable-visual-regression.yml +++ b/.github/workflows/reusable-visual-regression.yml @@ -28,6 +28,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/backend/src/Taskdeck.Api/Controllers/ThinkingStepController.cs b/backend/src/Taskdeck.Api/Controllers/ThinkingStepController.cs new file mode 100644 index 0000000000..9d4b348c49 --- /dev/null +++ b/backend/src/Taskdeck.Api/Controllers/ThinkingStepController.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Taskdeck.Api.Extensions; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; + +namespace Taskdeck.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/boards/{boardId}/cards/{cardId}/thinking/steps/{layerId}/{itemId}/card")] +public class ThinkingStepController : AuthenticatedControllerBase +{ + private readonly ThinkingStepService service; + public ThinkingStepController(ThinkingStepService service, IUserContext userContext) : base(userContext) + { this.service = service; } + + [HttpPost] + [RequestSizeLimit(4000)] + public async Task Promote(Guid boardId, Guid cardId, Guid layerId, Guid itemId, + PromoteThinkingStepDto dto, CancellationToken cancellationToken) + { + if (!TryGetCurrentUserId(out var userId, out var error)) return error!; + var result = await service.PromoteAsync(userId, boardId, cardId, layerId, itemId, dto, cancellationToken); + return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); + } +} diff --git a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs index 2ee10f9e05..644916ea54 100644 --- a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs @@ -29,6 +29,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); diff --git a/backend/src/Taskdeck.Application/DTOs/AuditAndExportDtos.cs b/backend/src/Taskdeck.Application/DTOs/AuditAndExportDtos.cs index 9e3abfdafb..63f3a80662 100644 --- a/backend/src/Taskdeck.Application/DTOs/AuditAndExportDtos.cs +++ b/backend/src/Taskdeck.Application/DTOs/AuditAndExportDtos.cs @@ -47,7 +47,7 @@ public record ImportCardDto( int Position, DateTimeOffset? DueDate, IEnumerable? Labels, - ThinkingMaterialDto? Thinking = null); + ThinkingMaterialDto? Thinking = null, Guid? SourceId = null); public record ImportLabelDto( string Name, diff --git a/backend/src/Taskdeck.Application/DTOs/ThinkingDeckDto.cs b/backend/src/Taskdeck.Application/DTOs/ThinkingDeckDto.cs index e7ef46d18b..2865f62346 100644 --- a/backend/src/Taskdeck.Application/DTOs/ThinkingDeckDto.cs +++ b/backend/src/Taskdeck.Application/DTOs/ThinkingDeckDto.cs @@ -4,3 +4,4 @@ namespace Taskdeck.Application.DTOs; public sealed record ThinkingDeckDto(Guid CardId, long Revision, int SchemaVersion, IReadOnlyList Layers, bool CanWrite); public sealed record SaveThinkingDeckDto(long ExpectedRevision, IReadOnlyList Layers); +public sealed record PromoteThinkingStepDto(long ExpectedRevision, Guid ColumnId, string Title); diff --git a/backend/src/Taskdeck.Application/Services/BoardJsonExportImportService.cs b/backend/src/Taskdeck.Application/Services/BoardJsonExportImportService.cs index f046c306a8..394c9542cb 100644 --- a/backend/src/Taskdeck.Application/Services/BoardJsonExportImportService.cs +++ b/backend/src/Taskdeck.Application/Services/BoardJsonExportImportService.cs @@ -88,9 +88,14 @@ public async Task> ExportBoardAsync(Guid boardId, Guid us a.GrantedAt)) .ToList(); + var exportedIds = cards.Select(card => card.Id).ToHashSet(); var thinkingDecks = _thinkingDecks is null ? null : (await _thinkingDecks.GetByCardIdsAsync(cards.Select(card => card.Id).ToArray(), default)) - .Select(deck => new ExportThinkingDeckDto(deck.CardId, new ThinkingMaterialDto(deck.SchemaVersion, deck.ReadLayers()))) + .Select(deck => new ExportThinkingDeckDto(deck.CardId, new ThinkingMaterialDto(deck.SchemaVersion, deck.ReadLayers().Select(layer => layer with + { + Items = layer.Items.Select(item => item.LinkedCardId.HasValue && !exportedIds.Contains(item.LinkedCardId.Value) + ? item with { LinkedCardId = null } : item).ToArray() + }).ToArray()))) .ToList(); var exportDto = new ExportBoardDto( @@ -133,7 +138,11 @@ public async Task> ImportBoardAsync(ImportBoardDto dto, var labels = dto.Labels ?? Enumerable.Empty(); var columns = dto.Columns ?? Enumerable.Empty(); - var cards = dto.Cards ?? Enumerable.Empty(); + var cards = (dto.Cards ?? Enumerable.Empty()).ToList(); + var cardIds = new Dictionary(); + foreach (var source in cards.Where(card => card.SourceId.HasValue)) + if (source.SourceId == Guid.Empty || !cardIds.TryAdd(source.SourceId!.Value, Guid.NewGuid())) + throw new DomainException(ErrorCodes.ValidationError, "Invalid or duplicate source card ID."); var board = new Board(dto.Name, dto.Description, userId); await _unitOfWork.Boards.AddAsync(board); @@ -169,7 +178,8 @@ public async Task> ImportBoardAsync(ImportBoardDto dto, if (!columnsByName.TryGetValue(importCard.ColumnName, out var column)) throw new DomainException(ErrorCodes.ValidationError, $"Column '{importCard.ColumnName}' referenced by card '{importCard.Title}' was not found"); - var card = new Card(board.Id, column.Id, importCard.Title, importCard.Description, importCard.DueDate, importCard.Position); + var card = new Card(importCard.SourceId.HasValue ? cardIds[importCard.SourceId.Value] : Guid.NewGuid(), + board.Id, column.Id, importCard.Title, importCard.Description, importCard.DueDate, importCard.Position); var uniqueCardLabelNames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var labelName in importCard.Labels ?? Enumerable.Empty()) @@ -200,10 +210,22 @@ public async Task> ImportBoardAsync(ImportBoardDto dto, { if (_thinkingDecks is null) throw new DomainException(ErrorCodes.ValidationError, "This host cannot import thinking material."); - if (importCard.Thinking.SchemaVersion != 1) + if (importCard.Thinking.SchemaVersion is not (1 or 2)) throw new DomainException(ErrorCodes.ValidationError, "Unsupported thinking material schema version."); var deck = new ThinkingDeck(card.Id); - deck.Replace(importCard.Thinking.Layers); + // Validate source IDs before remapping; links never point into the source board. + var material = importCard.Thinking.Layers; + var sourceDeck = new ThinkingDeck(importCard.SourceId ?? card.Id); + sourceDeck.Replace(material); + if (sourceDeck.SchemaVersion > importCard.Thinking.SchemaVersion) + throw new DomainException(ErrorCodes.ValidationError, "Linked thinking cards require material schema version 2."); + deck.Replace(material.Select(layer => layer with + { + Items = layer.Items.Select(item => item.LinkedCardId.HasValue + ? item with { LinkedCardId = cardIds.TryGetValue(item.LinkedCardId.Value, out var linkedId) ? linkedId + : throw new DomainException(ErrorCodes.ValidationError, "Thinking link references a card outside this import.") } + : item).ToArray() + }).ToArray()); _thinkingDecks.AddForImport(deck); } cardsImported++; @@ -362,7 +384,7 @@ internal static ImportBoardDto ConvertExportToImportDto(ExportBoardDto exportDto card.Position, card.DueDate, labelNames, - thinkingByCard.GetValueOrDefault(card.Id))); + thinkingByCard.GetValueOrDefault(card.Id), card.Id)); } return new ImportBoardDto( diff --git a/backend/src/Taskdeck.Application/Services/CaptureService.cs b/backend/src/Taskdeck.Application/Services/CaptureService.cs index 619120c0e7..739d6abf0f 100644 --- a/backend/src/Taskdeck.Application/Services/CaptureService.cs +++ b/backend/src/Taskdeck.Application/Services/CaptureService.cs @@ -858,7 +858,7 @@ public async Task> UpdateSuggestionAsync( // stored bytes. The record of what the user first typed or pasted survives every correction, // and a representation can still name the exact asset it was derived from. Staged into the // same unit of work as the queue row, so the edit and the new source commit together. - var durable = await SupersedeDurableTextAsync( + var durable = await UpdateDurableCaptureAsync( userId, item.Id, dto.Text, @@ -903,15 +903,16 @@ private async Task> UpdateLinkedTranscriptSuggestionAsync "The linked transcript cannot be corrected"); } - var normalizedText = NormalizeLineEndings(dto.Text); - var textChanged = !string.Equals(normalizedText, canonical.Text, StringComparison.Ordinal); var maxTextLength = CaptureRequestContract.MaxTranscriptTextLength; - if (normalizedText.Length > maxTextLength) + if (dto.Text.Length > maxTextLength) { return Result.Failure(ErrorCodes.ValidationError, $"Text exceeds maximum length of {maxTextLength} characters"); } + var normalizedText = NormalizeLineEndings(dto.Text); + var textChanged = !string.Equals(normalizedText, canonical.Text, StringComparison.Ordinal); + var updatedPayload = currentPayload with { Text = normalizedText, @@ -968,10 +969,10 @@ private async Task> UpdateLinkedTranscriptSuggestionAsync currentPayload.TitleHint, StringComparison.Ordinal)) { - durable = await SupersedeDurableTextAsync( + durable = await UpdateDurableCaptureAsync( userId, item.Id, - normalizedText, + textChanged ? dto.Text : null, updatedPayload.TitleHint, cancellationToken); } @@ -1031,10 +1032,11 @@ private static string NormalizeLineEndings(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); /// - /// Appends the corrected text as a superseding SourceAsset on the durable capture, if - /// there is one, and carries the edited title hint onto the aggregate in the same unit of work. - /// Returns the mutated aggregate so the caller's DTO reflects the new current text; null when - /// the capture is not (yet) durable, which leaves the queue-row reading intact. + /// Applies a corrected text as a superseding SourceAsset on the durable capture when + /// is provided, and carries the edited title hint onto the + /// aggregate in the same unit of work. Returns the mutated aggregate so the caller's DTO + /// reflects the new current text; null when the capture is not (yet) durable, which leaves the + /// queue-row reading intact. /// /// Deliberately NOT gated on DualWriteCaptures. That flag governs whether a NEW capture /// reaches the aggregate; it must never mean that an aggregate which already exists is allowed @@ -1042,10 +1044,10 @@ private static string NormalizeLineEndings(string text) => /// and turning it back on would serve that text through the read switch. /// /// - private async Task SupersedeDurableTextAsync( + private async Task UpdateDurableCaptureAsync( Guid userId, Guid captureId, - string text, + string? sourceText, string? titleHint, CancellationToken cancellationToken) { @@ -1062,7 +1064,11 @@ private static string NormalizeLineEndings(string text) => try { - capture.SupersedeInlineTextSource(text); + if (sourceText is not null) + { + capture.SupersedeInlineTextSource(sourceText); + } + // The queue payload carries the edited title hint, so the aggregate has to take it too -- // otherwise UserTitle silently keeps the pre-edit value forever. capture.Retitle(titleHint); @@ -1074,7 +1080,7 @@ private static string NormalizeLineEndings(string text) => // detects that and the reconcile pass repairs it on the next start. _logger?.LogWarning( ex, - "Context Fabric: could not record a superseding source for capture {CaptureId}; " + + "Context Fabric: could not update the durable capture {CaptureId}; " + "the edit still applied to the queue row and the backfill will reconcile it.", captureId); return null; @@ -1182,7 +1188,7 @@ private async Task CancelInternalAsync( DateTimeOffset queueUpdatedAt, CancellationToken cancellationToken) { - // Not gated on DualWriteCaptures, for the same reason as SupersedeDurableTextAsync: the flag + // Not gated on DualWriteCaptures, for the same reason as UpdateDurableCaptureAsync: the flag // decides whether new captures reach the aggregate, never whether an existing one may drift. if (_captureStore is null) { diff --git a/backend/src/Taskdeck.Application/Services/CardService.cs b/backend/src/Taskdeck.Application/Services/CardService.cs index 0a08e32686..6a99cdc036 100644 --- a/backend/src/Taskdeck.Application/Services/CardService.cs +++ b/backend/src/Taskdeck.Application/Services/CardService.cs @@ -55,26 +55,51 @@ public async Task> CreateCardAsync( Guid? cardId, Guid? actorUserId, CancellationToken cancellationToken = default) + { + try + { + var staged = await StageCardCreationAsync(dto, cardId, cancellationToken); + if (!staged.IsSuccess) return Result.Failure(staged.ErrorCode, staged.ErrorMessage); + var card = staged.Value; + await _unitOfWork.SaveChangesAsync(cancellationToken); + await _realtimeNotifier.NotifyBoardMutationAsync( + new BoardRealtimeEvent(card.BoardId, "card", "created", card.Id, DateTimeOffset.UtcNow), + cancellationToken); + await SafeLogAsync("card", card.Id, AuditAction.Created, actorUserId, $"title={card.Title}"); + + var createdCard = await _unitOfWork.Cards.GetByIdWithLabelsAsync(card.Id, cancellationToken); + return Result.Success(MapToDto(createdCard!)); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + + // Shared guarded writer. The caller must atomically commit staged entities, audit and + // links before publishing realtime. This method performs no save or notification. + internal async Task> StageCardCreationAsync( + CreateCardDto dto, Guid? cardId, CancellationToken cancellationToken) { try { // Verify board and column exist var board = await _unitOfWork.Boards.GetByIdAsync(dto.BoardId, cancellationToken); if (board == null) - return Result.Failure(ErrorCodes.NotFound, $"Board with ID {dto.BoardId} not found"); + return Result.Failure(ErrorCodes.NotFound, $"Board with ID {dto.BoardId} not found"); if (board.IsArchived) - return Result.Failure(ErrorCodes.InvalidOperation, ArchivedBoardWriteMessage); + return Result.Failure(ErrorCodes.InvalidOperation, ArchivedBoardWriteMessage); var column = await _unitOfWork.Columns.GetByIdWithCardsAsync(dto.ColumnId, cancellationToken); if (column == null) - return Result.Failure(ErrorCodes.NotFound, $"Column with ID {dto.ColumnId} not found"); + return Result.Failure(ErrorCodes.NotFound, $"Column with ID {dto.ColumnId} not found"); if (column.BoardId != dto.BoardId) - return Result.Failure(ErrorCodes.NotFound, $"Column with ID {dto.ColumnId} not found in board {dto.BoardId}"); + return Result.Failure(ErrorCodes.NotFound, $"Column with ID {dto.ColumnId} not found in board {dto.BoardId}"); // Check WIP limit if (column.WouldExceedWipLimitIfAdded()) - return Result.Failure(ErrorCodes.WipLimitExceeded, + return Result.Failure(ErrorCodes.WipLimitExceeded, $"Cannot add card, column '{column.Name}' has reached its WIP limit of {column.WipLimit}"); // Determine position (add to bottom) @@ -99,19 +124,9 @@ public async Task> CreateCardAsync( } board.RecordCardMutation(); - await _unitOfWork.SaveChangesAsync(cancellationToken); - await _realtimeNotifier.NotifyBoardMutationAsync( - new BoardRealtimeEvent(card.BoardId, "card", "created", card.Id, DateTimeOffset.UtcNow), - cancellationToken); - await SafeLogAsync("card", card.Id, AuditAction.Created, actorUserId, $"title={card.Title}"); - - var createdCard = await _unitOfWork.Cards.GetByIdWithLabelsAsync(card.Id, cancellationToken); - return Result.Success(MapToDto(createdCard!)); - } - catch (DomainException ex) - { - return Result.Failure(ex.ErrorCode, ex.Message); + return Result.Success(card); } + catch (DomainException ex) { return Result.Failure(ex.ErrorCode, ex.Message); } } public async Task> UpdateCardAsync( diff --git a/backend/src/Taskdeck.Application/Services/ThinkingDeckService.cs b/backend/src/Taskdeck.Application/Services/ThinkingDeckService.cs index 3ff8f50807..67dbd10d3b 100644 --- a/backend/src/Taskdeck.Application/Services/ThinkingDeckService.cs +++ b/backend/src/Taskdeck.Application/Services/ThinkingDeckService.cs @@ -23,7 +23,13 @@ public async Task> SaveAsync(Guid actorId, Guid boardId, var deck = await decks.GetAsync(cardId, ct) ?? new ThinkingDeck(cardId); if (dto.ExpectedRevision != deck.Revision) return Conflict(); - try { deck.Replace(dto.Layers); } + // Only promotion may introduce a card link. Removing a thought never removes its card. + var saved = deck.ReadLayers(); + if (dto.Layers is not null && dto.Layers.Where(layer => layer?.Items is not null).Any(layer => + layer.Items.Any(item => item?.LinkedCardId is not null && + saved.FirstOrDefault(old => old.Id == layer.Id)?.Items.FirstOrDefault(old => old.Id == item.Id)?.LinkedCardId != item.LinkedCardId))) + return Result.Failure(ErrorCodes.ValidationError, "Create linked cards through the saved step action."); + try { deck.Replace(dto.Layers!); } catch (DomainException ex) { return Result.Failure(ex.ErrorCode, ex.Message); } // Join the board's concurrency-token guard to the same save as the deck. // An archive committed after CheckAsync must reject both initial inserts and updates. diff --git a/backend/src/Taskdeck.Application/Services/ThinkingStepService.cs b/backend/src/Taskdeck.Application/Services/ThinkingStepService.cs new file mode 100644 index 0000000000..3c44102d8f --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/ThinkingStepService.cs @@ -0,0 +1,54 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +public sealed class ThinkingStepService( + ThinkingDeckService thinking, IThinkingDeckRepository decks, IUnitOfWork unit, + CardService writer, IBoardRealtimeNotifier notifier) +{ + public async Task> PromoteAsync(Guid actorId, Guid boardId, Guid cardId, + Guid layerId, Guid itemId, PromoteThinkingStepDto dto, CancellationToken ct) + { + var access = await thinking.GetAsync(actorId, boardId, cardId, ct); + if (!access.IsSuccess) return Result.Failure(access.ErrorCode, access.ErrorMessage); + if (!access.Value.CanWrite) + return Result.Failure(ErrorCodes.Forbidden, "This board is read-only."); + var deck = await decks.GetAsync(cardId, ct); + var layers = deck?.ReadLayers(); + var layer = layers?.SingleOrDefault(x => x.Id == layerId && x.Kind == "steps"); + var item = layer?.Items.SingleOrDefault(x => x.Id == itemId); + if (deck is null || layer is null || item is null) + return Result.Failure(ErrorCodes.NotFound, "Save this thinking step before creating a card."); + // A lost response or repeated click returns the existing link, even with the old revision. + // A deleted linked card remains a tombstone until the user explicitly removes the step. + if (item.LinkedCardId.HasValue) + return Result.Success(new ThinkingDeckDto(cardId, deck.Revision, deck.SchemaVersion, layers!, true)); + if (deck.Revision != dto.ExpectedRevision) + return Result.Failure(ErrorCodes.Conflict, "Thinking changed. Reload before creating this card."); + var childId = Guid.NewGuid(); + try + { + deck.Replace(layers!.Select(x => x.Id == layerId ? x with + { + Items = x.Items.Select(step => step.Id == itemId ? step with { LinkedCardId = childId, Completed = false } : step).ToArray() + } : x).ToArray()); + } + catch (DomainException ex) { return Result.Failure(ex.ErrorCode, ex.Message); } + var staged = await writer.StageCardCreationAsync(new CreateCardDto(boardId, dto.ColumnId, + dto.Title, item.Text, null, null), childId, ct); + if (!staged.IsSuccess) return Result.Failure(staged.ErrorCode, staged.ErrorMessage); + await unit.AuditLogs.AddAsync(new AuditLog("card", childId, AuditAction.Created, actorId, + $"Created from thinking step; sourceCard={cardId}; layer={layerId}; step={itemId}"), ct); + // One SaveChanges commits the child, board concurrency token, audit and deck CAS. + // A WIP/archive/deck race rolls back every staged write and emits no notification. + if (!await decks.SaveAsync(deck, dto.ExpectedRevision, ct)) + return Result.Failure(ErrorCodes.Conflict, "The board or thinking changed. Reload before retrying."); + await notifier.NotifyBoardMutationAsync(new BoardRealtimeEvent(boardId, "card", "created", childId, DateTimeOffset.UtcNow), ct); + return Result.Success(new ThinkingDeckDto(cardId, deck.Revision, deck.SchemaVersion, deck.ReadLayers(), true)); + } +} diff --git a/backend/src/Taskdeck.Domain/Entities/ThinkingDeck.cs b/backend/src/Taskdeck.Domain/Entities/ThinkingDeck.cs index 2099201bcf..98fb6e2b7e 100644 --- a/backend/src/Taskdeck.Domain/Entities/ThinkingDeck.cs +++ b/backend/src/Taskdeck.Domain/Entities/ThinkingDeck.cs @@ -3,7 +3,7 @@ namespace Taskdeck.Domain.Entities; -public sealed record ThinkingItem(Guid Id, string Text, bool Completed = false); +public sealed record ThinkingItem(Guid Id, string Text, bool Completed = false, Guid? LinkedCardId = null); public sealed record ThinkingLayer(Guid Id, string Kind, string Title, string Body, IReadOnlyList Items, Guid? SelectedOptionId = null); @@ -36,7 +36,9 @@ layer.Title is null || layer.Title.Length > 200 || layer.Body is null || layer.B var itemIds = new HashSet(); foreach (var item in layer.Items) if (item is null || item.Id == Guid.Empty || !itemIds.Add(item.Id) || - string.IsNullOrWhiteSpace(item.Text) || item.Text.Length > 2000) + string.IsNullOrWhiteSpace(item.Text) || item.Text.Length > 2000 || + item.LinkedCardId == Guid.Empty || item.LinkedCardId == CardId || + item.LinkedCardId.HasValue && (layer.Kind != "steps" || item.Completed)) throw new DomainException(ErrorCodes.ValidationError, "Invalid thinking item."); if (layer.Kind is "note" or "question" && layer.Items.Count != 0 || layer.SelectedOptionId.HasValue && (layer.Kind != "options" || !itemIds.Contains(layer.SelectedOptionId.Value)) || @@ -47,6 +49,9 @@ layer.Title is null || layer.Title.Length > 200 || layer.Body is null || layer.B if (json.Length > 100000) throw new DomainException(ErrorCodes.ValidationError, "Thinking deck exceeds 100,000 characters."); LayersJson = json; + // Older schema-1 readers ignore unknown item fields. Advertise links explicitly + // so their import validation refuses the material instead of silently losing links. + SchemaVersion = layers.Any(layer => layer.Items.Any(item => item.LinkedCardId.HasValue)) ? 2 : 1; Revision++; } } diff --git a/backend/tests/Taskdeck.Api.Tests/CaptureServiceTransactionIntegrationTests.cs b/backend/tests/Taskdeck.Api.Tests/CaptureServiceTransactionIntegrationTests.cs index e1764d9644..7258377c70 100644 --- a/backend/tests/Taskdeck.Api.Tests/CaptureServiceTransactionIntegrationTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/CaptureServiceTransactionIntegrationTests.cs @@ -156,4 +156,409 @@ [new TranscriptSegment(0, 0, "Speaker", 10)], } } } + + [Theory] + [InlineData("corrected\r\nsecond line", "corrected\nsecond line")] + [InlineData("corrected\rsecond line", "corrected\nsecond line")] + public async Task LinkedCorrection_PreservesSubmittedLineEndingsInDurableSource_WhileCanonicalTranscriptNormalizes( + string submittedText, + string normalizedText) + { + var dbPath = Path.Combine(Path.GetTempPath(), $"taskdeck-transcript-source-fidelity-{Guid.NewGuid():N}.db"); + try + { + var options = new DbContextOptionsBuilder() + .UseSqlite(TestSqlite.ConnectionString(dbPath)) + .Options; + await using var db = new TaskdeckDbContext(options); + await db.Database.MigrateAsync(); + + var fixture = CreateLinkedTranscriptFixture("original transcript"); + db.Users.Add(fixture.User); + db.LlmRequests.Add(fixture.Item); + db.Transcripts.Add(fixture.Original); + db.Captures.Add(fixture.DurableCapture); + await db.SaveChangesAsync(); + + var service = CreateTransactionalService( + db, + fixture.Item, + queueCasResult: true, + out var unitOfWork, + out var getReplacementPayload); + var result = await service.UpdateSuggestionAsync( + fixture.User.Id, + fixture.Item.Id, + new UpdateCaptureSuggestionDto(submittedText)); + + result.IsSuccess.Should().BeTrue(result.ErrorMessage); + result.Value.RawText.Should().Be(normalizedText); + var queuedPayload = getReplacementPayload(); + queuedPayload.Should().NotBeNull(); + CaptureRequestContract.ParseStoredPayload(queuedPayload!).Text.Should().Be(normalizedText); + unitOfWork.Verify(value => value.CommitTransactionAsync(It.IsAny()), Times.Once); + + db.ChangeTracker.Clear(); + var persistedTranscripts = await db.Transcripts + .AsNoTracking() + .Where(value => value.CreatedFromCaptureId == fixture.Item.Id) + .ToListAsync(); + persistedTranscripts.Should().HaveCount(2); + persistedTranscripts.Single(value => value.Id == fixture.Original.Id).Text + .Should().Be("original transcript"); + persistedTranscripts.Single(value => value.Id != fixture.Original.Id).Text + .Should().Be(normalizedText); + + var persistedAssets = await db.SourceAssets + .AsNoTracking() + .Include(value => value.TextPayload) + .Where(value => value.CaptureId == fixture.Item.Id) + .OrderBy(value => value.Ordinal) + .ToListAsync(); + persistedAssets.Should().HaveCount(2); + persistedAssets[0].TextPayload!.Text.Should().Be("original transcript"); + persistedAssets[0].IsActive.Should().BeFalse(); + persistedAssets[1].TextPayload!.Text.Should().Be(submittedText); + persistedAssets[1].IsActive.Should().BeTrue(); + } + finally + { + foreach (var suffix in new[] { "", "-wal", "-shm", "-journal" }) + { + var path = dbPath + suffix; + if (File.Exists(path)) + { + try { File.Delete(path); } + catch (IOException) { } + } + } + } + } + + [Theory] + [InlineData("original\ntranscript", "Retitled transcript")] + [InlineData("original\r\ntranscript", null)] + public async Task LinkedCorrection_TitleOnlyOrNormalizedEquivalentText_DoesNotAppendSource( + string submittedText, + string? titleHint) + { + var dbPath = Path.Combine(Path.GetTempPath(), $"taskdeck-transcript-title-only-{Guid.NewGuid():N}.db"); + try + { + var options = new DbContextOptionsBuilder() + .UseSqlite(TestSqlite.ConnectionString(dbPath)) + .Options; + await using var db = new TaskdeckDbContext(options); + await db.Database.MigrateAsync(); + + var fixture = CreateLinkedTranscriptFixture("original\ntranscript"); + db.Users.Add(fixture.User); + db.LlmRequests.Add(fixture.Item); + db.Transcripts.Add(fixture.Original); + db.Captures.Add(fixture.DurableCapture); + await db.SaveChangesAsync(); + + var service = CreateTransactionalService( + db, + fixture.Item, + queueCasResult: true, + out var unitOfWork, + out var getReplacementPayload); + var result = await service.UpdateSuggestionAsync( + fixture.User.Id, + fixture.Item.Id, + new UpdateCaptureSuggestionDto(submittedText, TitleHint: titleHint)); + + result.IsSuccess.Should().BeTrue(result.ErrorMessage); + var queuedPayload = getReplacementPayload(); + queuedPayload.Should().NotBeNull(); + CaptureRequestContract.ParseStoredPayload(queuedPayload!).Text.Should().Be("original\ntranscript"); + unitOfWork.Verify(value => value.CommitTransactionAsync(It.IsAny()), Times.Once); + + db.ChangeTracker.Clear(); + var persistedCapture = await db.Captures + .AsNoTracking() + .SingleAsync(value => value.Id == fixture.Item.Id); + persistedCapture.UserTitle.Should().Be(titleHint); + + var persistedAssets = await db.SourceAssets + .AsNoTracking() + .Include(value => value.TextPayload) + .Where(value => value.CaptureId == fixture.Item.Id) + .ToListAsync(); + persistedAssets.Should().ContainSingle(); + persistedAssets[0].TextPayload!.Text.Should().Be("original\ntranscript"); + persistedAssets[0].IsActive.Should().BeTrue(); + + (await db.Transcripts.AsNoTracking() + .CountAsync(value => value.CreatedFromCaptureId == fixture.Item.Id)) + .Should().Be(1); + } + finally + { + foreach (var suffix in new[] { "", "-wal", "-shm", "-journal" }) + { + var path = dbPath + suffix; + if (File.Exists(path)) + { + try { File.Delete(path); } + catch (IOException) { } + } + } + } + } + + [Fact] + public async Task LinkedCorrection_ShouldRejectRawTranscriptOverCapBeforeTransaction() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"taskdeck-transcript-raw-cap-rejection-{Guid.NewGuid():N}.db"); + try + { + var options = new DbContextOptionsBuilder() + .UseSqlite(TestSqlite.ConnectionString(dbPath)) + .Options; + await using var db = new TaskdeckDbContext(options); + await db.Database.MigrateAsync(); + + var fixture = CreateLinkedTranscriptFixture("original transcript", "Original title"); + db.Users.Add(fixture.User); + db.LlmRequests.Add(fixture.Item); + db.Transcripts.Add(fixture.Original); + db.Captures.Add(fixture.DurableCapture); + await db.SaveChangesAsync(); + + var oversizedText = string.Concat(Enumerable.Repeat("x\r\n", 66_667)); + oversizedText.Length.Should().Be(CaptureRequestContract.MaxTranscriptTextLength + 1); + oversizedText.Replace("\r\n", "\n", StringComparison.Ordinal).Length + .Should().BeLessThan(CaptureRequestContract.MaxTranscriptTextLength); + + var service = CreateTransactionalService( + db, + fixture.Item, + queueCasResult: true, + out var unitOfWork, + out var getReplacementPayload); + var result = await service.UpdateSuggestionAsync( + fixture.User.Id, + fixture.Item.Id, + new UpdateCaptureSuggestionDto(oversizedText, TitleHint: "Changed title")); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(Domain.Exceptions.ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain(CaptureRequestContract.MaxTranscriptTextLength.ToString()); + getReplacementPayload().Should().BeNull(); + unitOfWork.Verify(value => value.BeginTransactionAsync(It.IsAny()), Times.Never); + unitOfWork.Verify(value => value.SaveChangesAsync(It.IsAny()), Times.Never); + unitOfWork.Verify(value => value.CommitTransactionAsync(It.IsAny()), Times.Never); + + db.ChangeTracker.Clear(); + var persistedItem = await db.LlmRequests.AsNoTracking().SingleAsync(value => value.Id == fixture.Item.Id); + var persistedPayload = CaptureRequestContract.ParseStoredPayload(persistedItem.Payload); + persistedPayload.Text.Should().Be("original transcript"); + persistedPayload.TitleHint.Should().Be("Original title"); + + var persistedTranscript = await db.Transcripts + .AsNoTracking() + .SingleAsync(value => value.CreatedFromCaptureId == fixture.Item.Id); + persistedTranscript.Text.Should().Be("original transcript"); + + var persistedCapture = await db.Captures + .AsNoTracking() + .SingleAsync(value => value.Id == fixture.Item.Id); + persistedCapture.UserTitle.Should().Be("Original title"); + var persistedAssets = await db.SourceAssets + .AsNoTracking() + .Include(value => value.TextPayload) + .Where(value => value.CaptureId == fixture.Item.Id) + .ToListAsync(); + persistedAssets.Should().ContainSingle(); + persistedAssets[0].TextPayload!.Text.Should().Be("original transcript"); + persistedAssets[0].IsActive.Should().BeTrue(); + } + finally + { + foreach (var suffix in new[] { "", "-wal", "-shm", "-journal" }) + { + var path = dbPath + suffix; + if (File.Exists(path)) + { + try { File.Delete(path); } + catch (IOException) { } + } + } + } + } + + [Fact] + public async Task LinkedCorrection_ShouldAcceptExactRawTranscriptCapAndNormalizeCanonicalData() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"taskdeck-transcript-raw-cap-accepted-{Guid.NewGuid():N}.db"); + try + { + var options = new DbContextOptionsBuilder() + .UseSqlite(TestSqlite.ConnectionString(dbPath)) + .Options; + await using var db = new TaskdeckDbContext(options); + await db.Database.MigrateAsync(); + + var fixture = CreateLinkedTranscriptFixture("original transcript"); + db.Users.Add(fixture.User); + db.LlmRequests.Add(fixture.Item); + db.Transcripts.Add(fixture.Original); + db.Captures.Add(fixture.DurableCapture); + await db.SaveChangesAsync(); + + var exactCapText = string.Concat(Enumerable.Repeat("x\r\n", 66_666)) + "xx"; + exactCapText.Length.Should().Be(CaptureRequestContract.MaxTranscriptTextLength); + var normalizedText = exactCapText.Replace("\r\n", "\n", StringComparison.Ordinal); + normalizedText.Length.Should().BeLessThan(exactCapText.Length); + + var service = CreateTransactionalService( + db, + fixture.Item, + queueCasResult: true, + out var unitOfWork, + out var getReplacementPayload); + var result = await service.UpdateSuggestionAsync( + fixture.User.Id, + fixture.Item.Id, + new UpdateCaptureSuggestionDto(exactCapText)); + + result.IsSuccess.Should().BeTrue(result.ErrorMessage); + result.Value.RawText.Should().Be(normalizedText); + var queuedPayload = getReplacementPayload(); + queuedPayload.Should().NotBeNull(); + CaptureRequestContract.ParseStoredPayload(queuedPayload!).Text.Should().Be(normalizedText); + unitOfWork.Verify(value => value.CommitTransactionAsync(It.IsAny()), Times.Once); + + db.ChangeTracker.Clear(); + var persistedTranscripts = await db.Transcripts + .AsNoTracking() + .Where(value => value.CreatedFromCaptureId == fixture.Item.Id) + .ToListAsync(); + persistedTranscripts.Should().HaveCount(2); + persistedTranscripts.Single(value => value.Id != fixture.Original.Id).Text.Should().Be(normalizedText); + + var persistedAssets = await db.SourceAssets + .AsNoTracking() + .Include(value => value.TextPayload) + .Where(value => value.CaptureId == fixture.Item.Id) + .OrderBy(value => value.Ordinal) + .ToListAsync(); + persistedAssets.Should().HaveCount(2); + persistedAssets[0].TextPayload!.Text.Should().Be("original transcript"); + persistedAssets[0].IsActive.Should().BeFalse(); + persistedAssets[1].TextPayload!.Text.Should().Be(exactCapText); + persistedAssets[1].IsActive.Should().BeTrue(); + } + finally + { + foreach (var suffix in new[] { "", "-wal", "-shm", "-journal" }) + { + var path = dbPath + suffix; + if (File.Exists(path)) + { + try { File.Delete(path); } + catch (IOException) { } + } + } + } + } + + private static (User User, LlmRequest Item, Transcript Original, Taskdeck.Domain.Entities.Capture DurableCapture) + CreateLinkedTranscriptFixture(string canonicalText, string? userTitle = null) + { + var user = new User("source-fidelity", "source-fidelity@example.com", "hash"); + var item = new LlmRequest( + user.Id, + CaptureRequestContract.RequestTypeTranscriptV1, + CaptureRequestContract.SerializePayload( + new CapturePayloadV1(1, CaptureSource.TranscriptPaste, canonicalText, TitleHint: userTitle))); + item.MarkAsProcessing(); + item.MarkAsCompleted(); + var original = new Transcript( + user.Id, + CaptureSource.TranscriptPaste, + canonicalText, + createdFromCaptureId: item.Id); + item.AttachTranscript(original.Id); + var durableCapture = Taskdeck.Domain.Entities.Capture.FromQueueRequest( + item.Id, + user.Id, + CaptureSource.TranscriptPaste, + contextBoardId: null, + capturedAtClient: null, + userTitle: userTitle, + capturedAtServer: item.CreatedAt, + sourceText: canonicalText); + + return (user, item, original, durableCapture); + } + + private static CaptureService CreateTransactionalService( + TaskdeckDbContext db, + LlmRequest item, + bool queueCasResult, + out Mock unitOfWork, + out Func getReplacementPayload) + { + var queue = new Mock(); + string? replacementPayload = null; + queue.Setup(repository => repository.GetByIdAsync(item.Id, It.IsAny())) + .ReturnsAsync(item); + queue.Setup(repository => repository.TryCorrectLinkedTranscriptCaptureAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (_, _, _, _, _, _, payload, _) => replacementPayload = payload) + .ReturnsAsync(queueCasResult); + + unitOfWork = new Mock(); + unitOfWork.SetupGet(value => value.LlmQueue).Returns(queue.Object); + IDbContextTransaction? transaction = null; + unitOfWork.Setup(value => value.BeginTransactionAsync(It.IsAny())) + .Returns(async (CancellationToken cancellationToken) => + { + transaction = await db.Database.BeginTransactionAsync(cancellationToken); + }); + unitOfWork.Setup(value => value.SaveChangesAsync(It.IsAny())) + .Returns((CancellationToken cancellationToken) => db.SaveChangesAsync(cancellationToken)); + unitOfWork.Setup(value => value.RollbackTransactionAsync(It.IsAny())) + .Returns(async (CancellationToken cancellationToken) => + { + if (transaction is not null) + { + await transaction.RollbackAsync(cancellationToken); + await transaction.DisposeAsync(); + transaction = null; + } + }); + unitOfWork.Setup(value => value.CommitTransactionAsync(It.IsAny())) + .Returns(async (CancellationToken cancellationToken) => + { + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + await transaction.DisposeAsync(); + transaction = null; + } + }); + + getReplacementPayload = () => replacementPayload; + + return new CaptureService( + unitOfWork.Object, + new Mock().Object, + captureStore: new EfCaptureStore(db), + contextFabricSettings: new ContextFabricSettings { DualWriteCaptures = false }, + backfillStore: null, + logger: null, + transcriptRepository: new TranscriptRepository(db)); + } } diff --git a/backend/tests/Taskdeck.Api.Tests/ThinkingStepApiTests.cs b/backend/tests/Taskdeck.Api.Tests/ThinkingStepApiTests.cs new file mode 100644 index 0000000000..985e7a3d96 --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/ThinkingStepApiTests.cs @@ -0,0 +1,207 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Taskdeck.Api.Tests.Support; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; +using Taskdeck.Infrastructure.Persistence; +using Taskdeck.Infrastructure.Repositories; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public sealed class ThinkingStepApiTests(TestWebApplicationFactory factory) : IClassFixture +{ + [Fact] + public async Task PromotionIsIdempotentAndRemovalKeepsChild_WithActorAuditAndPortableLinks() + { + using var client = factory.CreateClient(); + var (actor, board, parent, layer, item) = await Setup(client); + var request = new PromoteThinkingStepDto(1, parent.ColumnId, "Do the first step"); + var first = await client.PostAsJsonAsync(Url(board.Id, parent.Id, layer.Id, item.Id), request); + first.EnsureSuccessStatusCode(); + var deck = (await first.Content.ReadFromJsonAsync())!; + var childId = deck.Layers[0].Items[0].LinkedCardId!.Value; + deck.Revision.Should().Be(2); + deck.SchemaVersion.Should().Be(2); + var retry = await client.PostAsJsonAsync(Url(board.Id, parent.Id, layer.Id, item.Id), request); + retry.EnsureSuccessStatusCode(); + (await retry.Content.ReadFromJsonAsync())!.Layers[0].Items[0].LinkedCardId.Should().Be(childId); + var cards = (await client.GetFromJsonAsync>($"/api/boards/{board.Id}/cards"))!; + cards.Should().HaveCount(2); + cards.Single(card => card.Id == childId).Description.Should().Be(item.Text); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var audit = await db.Set().SingleAsync(row => row.EntityId == childId); + audit.UserId.Should().Be(actor); + audit.Action.Should().Be(AuditAction.Created); + audit.Changes.Should().Contain(parent.Id.ToString()); + } + var exported = await client.GetFromJsonAsync($"/api/export/boards/{board.Id}/json"); + exported!.ThinkingDecks![0].Material.SchemaVersion.Should().Be(2); + // A payload must not claim compatibility with the old reader that ignores links. + var mislabeled = exported with { ThinkingDecks = exported.ThinkingDecks.Select(value => + value with { Material = value.Material with { SchemaVersion = 1 } }).ToList() }; + (await client.PostAsJsonAsync("/api/import/boards/json", mislabeled)).StatusCode.Should().Be(HttpStatusCode.BadRequest); + var importedResponse = await client.PostAsJsonAsync("/api/import/boards/json", exported); + importedResponse.EnsureSuccessStatusCode(); + var imported = (await importedResponse.Content.ReadFromJsonAsync())!; + var copies = (await client.GetFromJsonAsync>($"/api/boards/{imported.BoardId}/cards"))!; + var copiedParent = copies.Single(card => card.Title == parent.Title); + var copiedChild = copies.Single(card => card.Title == "Do the first step"); + var copiedDeck = (await client.GetFromJsonAsync(DeckUrl(imported.BoardId!.Value, copiedParent.Id)))!; + copiedDeck.Layers[0].Items[0].LinkedCardId.Should().Be(copiedChild.Id).And.NotBe(childId); + copiedDeck.SchemaVersion.Should().Be(2); + (await client.PutAsJsonAsync(DeckUrl(board.Id, parent.Id), new SaveThinkingDeckDto(2, []))).EnsureSuccessStatusCode(); + (await client.GetFromJsonAsync(DeckUrl(board.Id, parent.Id)))!.SchemaVersion.Should().Be(1); + (await client.GetFromJsonAsync>($"/api/boards/{board.Id}/cards"))!.Should().HaveCount(2); + } + + [Fact] + public async Task RejectsUnauthenticatedForeignViewerWrongBoardAndSpoofedLinks() + { + using var owner = factory.CreateClient(); + using var other = factory.CreateClient(); + using var anonymous = factory.CreateClient(); + var (_, board, parent, layer, item) = await Setup(owner); + var request = new PromoteThinkingStepDto(1, parent.ColumnId, "A child"); + var url = Url(board.Id, parent.Id, layer.Id, item.Id); + (await anonymous.PostAsJsonAsync(url, request)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + var viewer = await ApiTestHarness.AuthenticateAsync(other, "step-viewer"); + (await other.PostAsJsonAsync(url, request)).StatusCode.Should().Be(HttpStatusCode.Forbidden); + (await owner.PostAsJsonAsync($"/api/boards/{board.Id}/access", new GrantAccessDto(board.Id, viewer.UserId, UserRole.Viewer))).EnsureSuccessStatusCode(); + (await other.PostAsJsonAsync(url, request)).StatusCode.Should().Be(HttpStatusCode.Forbidden); + var second = await ApiTestHarness.CreateBoardAsync(owner, "foreign step"); + (await owner.PostAsJsonAsync(Url(second.Id, parent.Id, layer.Id, item.Id), request)).StatusCode.Should().Be(HttpStatusCode.NotFound); + var forged = layer with { Items = [item with { LinkedCardId = Guid.NewGuid() }] }; + (await owner.PutAsJsonAsync(DeckUrl(board.Id, parent.Id), new SaveThinkingDeckDto(1, [forged]))).StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ConcurrentPromotionCreatesOnlyOneCard_AndDeletedLinksDoNotRecreateIt() + { + using var client = factory.CreateClient(); + var (_, board, parent, layer, item) = await Setup(client); + var url = Url(board.Id, parent.Id, layer.Id, item.Id); + var request = new PromoteThinkingStepDto(1, parent.ColumnId, "One child"); + var responses = await Task.WhenAll(client.PostAsJsonAsync(url, request), client.PostAsJsonAsync(url, request)); + responses.Should().Contain(response => response.IsSuccessStatusCode); + responses.Should().OnlyContain(response => response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.Conflict); + var deck = (await client.GetFromJsonAsync(DeckUrl(board.Id, parent.Id)))!; + var childId = deck.Layers[0].Items[0].LinkedCardId!.Value; + (await client.GetFromJsonAsync>($"/api/boards/{board.Id}/cards"))!.Should().HaveCount(2); + (await client.DeleteAsync($"/api/boards/{board.Id}/cards/{childId}")).EnsureSuccessStatusCode(); + (await client.PostAsJsonAsync(url, request)).EnsureSuccessStatusCode(); + (await client.GetFromJsonAsync>($"/api/boards/{board.Id}/cards"))!.Should().HaveCount(1); + var exported = (await client.GetFromJsonAsync($"/api/export/boards/{board.Id}/json"))!; + exported.ThinkingDecks![0].Material.Layers[0].Items[0].LinkedCardId.Should().BeNull(); + // The source still has its tombstone; export normalization does not mutate it. + (await client.GetFromJsonAsync(DeckUrl(board.Id, parent.Id)))!.Layers[0].Items[0].LinkedCardId.Should().Be(childId); + } + + [Theory] + [InlineData("stale")] + [InlineData("wip")] + [InlineData("archive")] + [InlineData("column")] + [InlineData("title")] + public async Task RefusalLeavesDeckAndBoardUnchanged(string reason) + { + using var client = factory.CreateClient(); + var (_, board, parent, layer, item) = await Setup(client); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + if (reason == "archive") (await db.Boards.FindAsync(board.Id))!.Archive(); + if (reason == "wip") (await db.Columns.FindAsync(parent.ColumnId))!.Update(wipLimit: 1); + await db.SaveChangesAsync(); + } + var response = await client.PostAsJsonAsync(Url(board.Id, parent.Id, layer.Id, item.Id), + new PromoteThinkingStepDto(reason == "stale" ? 0 : 1, reason == "column" ? Guid.NewGuid() : parent.ColumnId, + reason == "title" ? new string('x', 201) : "A child")); + response.IsSuccessStatusCode.Should().BeFalse(); + var saved = (await client.GetFromJsonAsync(DeckUrl(board.Id, parent.Id)))!; + saved.Revision.Should().Be(1); + saved.Layers[0].Items[0].LinkedCardId.Should().BeNull(); + (await client.GetFromJsonAsync>($"/api/boards/{board.Id}/cards"))!.Should().HaveCount(1); + } + + [Fact] + public async Task ImportRejectsLinkedCardsOutsidePayloadWithoutCreatingBoard() + { + using var client = factory.CreateClient(); + var (_, board, parent, layer, item) = await Setup(client); + var exported = (await client.GetFromJsonAsync($"/api/export/boards/{board.Id}/json"))!; + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var before = await db.Boards.CountAsync(); + var invalid = exported with { ThinkingDecks = [new(parent.Id, new(2, + [layer with { Items = [item with { LinkedCardId = Guid.NewGuid() }] }]))] }; + (await client.PostAsJsonAsync("/api/import/boards/json", invalid)).StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await db.Boards.CountAsync()).Should().Be(before); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ConcurrentArchiveOrDeckSaveRollsBackChildAndAuditBeforeNotification(bool archive) + { + using var client = factory.CreateClient(); + var (actor, board, parent, layer, item) = await Setup(client); + using var scope = factory.Services.CreateScope(); + var services = scope.ServiceProvider; + var db = services.GetRequiredService(); + var realDecks = new ThinkingDeckRepository(db); + var interleaved = new Mock(); + interleaved.Setup(x => x.GetAsync(parent.Id, It.IsAny())).Returns((Guid id, CancellationToken ct) => realDecks.GetAsync(id, ct)); + interleaved.Setup(x => x.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (ThinkingDeck deck, long revision, CancellationToken ct) => + { + using var otherScope = factory.Services.CreateScope(); + var otherDb = otherScope.ServiceProvider.GetRequiredService(); + if (archive) { (await otherDb.Boards.FindAsync(board.Id))!.Archive(); await otherDb.SaveChangesAsync(ct); } + else + { + var repository = new ThinkingDeckRepository(otherDb); + var winner = (await repository.GetAsync(parent.Id, ct))!; + winner.Replace([layer with { Title = "Concurrent edit" }]); + (await repository.SaveAsync(winner, 1, ct)).Should().BeTrue(); + } + return await realDecks.SaveAsync(deck, revision, ct); + }); + var notifier = new Mock(); + var service = new ThinkingStepService(services.GetRequiredService(), interleaved.Object, + services.GetRequiredService(), services.GetRequiredService(), notifier.Object); + var result = await service.PromoteAsync(actor, board.Id, parent.Id, layer.Id, item.Id, + new PromoteThinkingStepDto(1, parent.ColumnId, "Never commit me"), default); + result.IsSuccess.Should().BeFalse(); + notifier.Verify(x => x.NotifyBoardMutationAsync(It.IsAny(), It.IsAny()), Times.Never); + using var verify = factory.Services.CreateScope(); + var finalDb = verify.ServiceProvider.GetRequiredService(); + (await finalDb.Cards.CountAsync(card => card.BoardId == board.Id)).Should().Be(1); + (await finalDb.Set().AnyAsync(row => row.Changes != null && row.Changes.Contains($"sourceCard={parent.Id}"))).Should().BeFalse(); + } + + private async Task<(Guid Actor, BoardDto Board, CardDto Parent, ThinkingLayer Layer, ThinkingItem Item)> Setup(HttpClient client) + { + var actor = await ApiTestHarness.AuthenticateAsync(client, "step-owner"); + var board = await ApiTestHarness.CreateBoardAsync(client, "Linked steps"); + var columnResponse = await client.PostAsJsonAsync($"/api/boards/{board.Id}/columns", new CreateColumnDto(board.Id, "To do", null, null)); + var column = (await columnResponse.Content.ReadFromJsonAsync())!; + var cardResponse = await client.PostAsJsonAsync($"/api/boards/{board.Id}/cards", new CreateCardDto(board.Id, column.Id, "Parent task", null, null, null)); + var card = (await cardResponse.Content.ReadFromJsonAsync())!; + var item = new ThinkingItem(Guid.NewGuid(), "Read source and implement"); + var layer = new ThinkingLayer(Guid.NewGuid(), "steps", "Next steps", "", [item]); + (await client.PutAsJsonAsync(DeckUrl(board.Id, card.Id), new SaveThinkingDeckDto(0, [layer]))).EnsureSuccessStatusCode(); + return (actor.UserId, board, card, layer, item); + } + private static string DeckUrl(Guid board, Guid card) => $"/api/boards/{board}/cards/{card}/thinking"; + private static string Url(Guid board, Guid card, Guid layer, Guid item) => $"{DeckUrl(board, card)}/steps/{layer}/{item}/card"; +} diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index e642d90c28..5a0be35703 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -65,6 +65,19 @@ behaviour. ## Dated delivery and governance updates (newest first) +### 2026-09-09 — Overhaul continuation: saved thinking into board work (#2808) + +PR #2807 is merged, including its SQLite rollback correction. The next vertical adds explicit +saved-step promotion over the guarded manual card writer. Card, link, board token and actor audit +commit together; realtime follows success. Revision/WIP/archive conflicts preserve the source, +repeated promotion does not duplicate a card, and shared board import remaps links to new IDs. +The browser flow retains Classic/Studio/Companion/Unified and reads actual linked-card status. +The comparison follow-through adds explicit scenarios/outcomes and backend version attribution. +Tracked insight/memory operation-order and board-discovery retry defects receive focused regressions. +Separate dependency edges, contextual Companion, personal continuity, source/audio integration and +optional intelligence/attention remain on the [overhaul ledger](product/WORKSPACE_OVERHAUL.md). + + ## Delivered PR integration (2026-09-09, #2235) Integration #2815 landed as `22029c760` with thirteen source candidates preserved and required hosted qualification passed. The final #2795 receipt repair is delivered; the larger #2004 acceptance remains open for its tracked recovery and causal-order residuals. The four dependency/CI candidates were merged under the maintainer's explicit four-PR delegation, with their review afterward. The separate #2819 candidate finishes the unused-dispatcher deletion for #2007; its source review, unchanged patch and 36 current-base shortcut tests are recorded on the PR, with hosted qualification still required. No release or human acknowledgement is inferred. diff --git a/docs/STATUS.md b/docs/STATUS.md index b3ff1e9177..4e6d61a644 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -31,7 +31,8 @@ September 8 parallel delivery wave (merged work; #2235): Workspace overhaul (#2800, integrated implementation; no release or working-database migration claimed): - Four switchable experiences and Zen/Studio/Control presentation share existing work and retain Classic as the default. Grove/Grove Night extend the existing theme tokens. Zen card disclosure preserves due/blocked/trust information; Control adds compact operational cards. -- Thinking Decks persist ordered typed layers with revision conflicts and board JSON portability. Saved questions can have private answers with immutable source evidence; answers never appear in shared decks or board exports. Private Memory supports correction history, archive/restore and an explicit JSON download. Quiet insights currently scan recorded blockers and unknown/needs-review memories on request, with durable suppression and source revalidation. +- Thinking Decks persist ordered typed layers with revision conflicts and board JSON portability. Saved steps can explicitly create linked cards with atomic WIP/archive/concurrency checks, actor audit and post-commit notification. Retry reuses the link; deleting thinking retains cards; import remaps links. Linked status reads the real card on load/refresh. Saved questions can have private answers with immutable source evidence; answers never appear in shared decks or board exports. Private Memory supports correction history, archive/restore and an explicit JSON download. Quiet insights currently scan recorded blockers and unknown/needs-review memories on request, with durable suppression and source revalidation. +- Comparison observations now require a scenario and explicit outcome, leave ease unrated until chosen, and export backend-reported version attribution (or null) with format version 2. Insight analysis settles independently of read generations; Memory creation waits for the first list; failed board discovery retries at the board boundary. - Five integrated Chromium journeys exercise capture/review/explicit apply across experiences, draft continuity, stale thinking saves, private question answers, memory history/export, board disclosure, and desktop/phone layouts. Automated accessibility checks cover the new Home and card surfaces. The [resource/feature map](product/WORKSPACE_OVERHAUL.md) and [validation ledger](product/WORKSPACE_OVERHAUL_VALIDATION.md) preserve exact scope and remaining source, linked-step, contextual companion, model/audio and attention work. No statistical A/B, microphone, new model intelligence, deployment or owner acceptance is claimed. Transcript prompt identity (#2211, current implementation): diff --git a/docs/product/WORKSPACE_OVERHAUL.md b/docs/product/WORKSPACE_OVERHAUL.md index 1dc519b4a9..931fc83f00 100644 --- a/docs/product/WORKSPACE_OVERHAUL.md +++ b/docs/product/WORKSPACE_OVERHAUL.md @@ -1,6 +1,6 @@ # Switchable workspace overhaul -Last Updated: 2026-09-08 +Last Updated: 2026-09-09 Delivery tracker: [#2800](https://github.com/Chris0Jeky/Taskdeck/issues/2800). @@ -51,18 +51,21 @@ explicit approve action followed by a separate apply action and the existing con | Capability | Integrated behavior | Status and limits | | --- | --- | --- | | Switchable experiences and prototype themes | Persistent preferences, one mounted route, four Home/navigation treatments, Grove/Grove Night | Implemented; Classic remains default. Board Zen disclosure and Control density preserve blocked/due/trust information | -| Thinking Deck | Ordered note/question/options/steps/thread layers, revision checks, stack/path, preserved alternatives, board JSON portability | Implemented; Path is a connected vertical reading view. Steps remain thinking checklists; they do not create cards or change status | +| Thinking Deck | Ordered note/question/options/steps/thread layers, revision checks, stack/path, preserved alternatives, board JSON portability | Implemented; Path is a connected vertical reading view. Saved steps can explicitly create linked board cards. Unlinked checkboxes remain thinking-only; linked status is read from the real card | | Quiet insights | Explicit analysis of blocked cards and unknown/needs-review private memory; durable dismiss/snooze/mute state and revalidation | Implemented structural rules only; no semantic/model inference, automatic scans or interruption | | Questions and memory | Saved shared questions can have private answers; source snapshots, statements/assumptions/unknowns, corrections and archive/restore | Implemented. Thinking is board-shared; answers, memory and insights are private to the user. Original answer/evidence and correction history survive; archive excludes the active memory list | | Memory portability and account privacy | Private JSON download plus both account export formats include originals, evidence, source references and history; account deletion erases private memories, revisions and insights | Implemented for active and archived records. The board-scoped download uses two authenticated reads, not an atomic backup or import format. Shared board exports never include private answers; collaborators' records survive account deletion | | Companion | Existing accountable chat embedded in Companion Home and contextual links to existing chat/review | Implemented entry point; no new model provider or memory retrieval integration. Unified's board-side contextual companion remains next | -| Comparison | User-initiated session-only ratings and notes with JSON download | Implemented; records selected experience/detail/theme. No telemetry, random assignment or statistical A/B claim | +| Comparison | User-initiated session-only scenarios, explicit completion outcomes, optional ease ratings and notes with JSON download | Implemented; version-2 export records selected experience/detail/theme and backend-reported product version (null if unavailable). No telemetry, random assignment or statistical A/B claim | | In-place proposal overlays | Existing authoritative diff rendered at affected board objects | Next; preserve effective proposal revision, subset-selection semantics and explicit apply gate | -| Linked steps, connections and continuity | Guarded child-card promotion, live dependency links, chosen daily plan and last-worked continuation | Next; preserve transactional card writer, WIP, audit and realtime consistency. Current agenda is derived from existing Today data, not last-worked tracking | +| Linked steps | Explicit title/destination, atomic child-card/link/audit creation, repeat-safe requests, refreshed real card status and portable links | Implemented; shares the guarded card writer and board concurrency token. Removing thinking never deletes linked cards. Separate dependency edges are still next | +| Connections and continuity | Dependency links, chosen daily plan and last-worked continuation | Next. Current agenda is derived from existing Today data, not last-worked tracking | | Audio answers and unified source evidence | Context Fabric original/representation pipeline and typed source anchors | Later; new memory currently preserves evidence directly, not as Capture/SourceAsset records. No microphone/transcription integration added here | | Model-generated observations and recall | Bounded candidate producer and authorized knowledge retrieval | Later; requires grounding, fresh evidence, usefulness corpus, privacy and budget proof | | Optional nudges | Opt-in attention policy after usefulness is established | Later; requires non-intrusion evaluation, focus/input suppression and shared user budgets | +Insight actions wait for active analysis to settle, Memory creation waits for the initial list, and Retry repeats failed board discovery before reading content. These guards prevent overlapping operations from stranding the workspace or hiding a newly saved memory. + Implementation above is the integrated branch behavior. [Follow-up delivery tracker #2808](https://github.com/Chris0Jeky/Taskdeck/issues/2808) owns the remaining prototype capabilities. [Validation and remaining delivery work](WORKSPACE_OVERHAUL_VALIDATION.md) records direct proving commands and limits; source screenshots are not @@ -78,7 +81,18 @@ route and open card. Home changes retain an unsaved capture or composed Companio Open a card, then **Open thinking deck**. Save a question layer before choosing **Your private answer**. That answer is separate from the shared question. Mark uncertain context **Unknown** or **Needs review**, then use **Quiet insights → Analyze now** for that board. **Memory** supports correction, original history, -archive/restore and a private JSON download. **Experiences** offers the manual comparison protocol. +archive/restore and a private JSON download. **Experiences** offers the manual comparison protocol: choose a scenario and observed outcome; the ease rating starts unrated. Export observations before reloading or signing out. + +For a **steps** layer, save thinking and choose **Create card from step…**. Choose a title and +column, then explicitly **Create linked card**. WIP, board permissions and archive state apply. +Repeated requests reuse the saved link. **Refresh card status** reads the card's current title, +column and blocker state; a checklist tick is never used to infer completion of a linked card. +Removing a step/layer keeps its cards. A deleted card shows as unavailable and is not recreated by +retrying the old promotion. Board JSON import remaps links to the newly imported cards; links to +already-deleted cards become unlinked thinking items in the export, leaving source tombstones intact. +Linked material uses thinking schema version 2, so older importers reject it instead of silently +discarding relationships. Ordinary decks retain version 1; the current importer accepts both. +This adds no database migration and no dependency edge or automatic proposal execution. The three additive database migrations create thinking/insight/memory tables and question-source columns. Follow [UPGRADING.md](../../UPGRADING.md) when updating an existing instance. The integration's automated diff --git a/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md b/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md index fefb346681..c2db6aea1a 100644 --- a/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md +++ b/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md @@ -51,10 +51,11 @@ counts are not used as evidence. Further implementation is tracked in ## Remaining sequence -1. **Linked steps and dependency connections.** Reuse the guarded card writer with transaction-safe - post-commit notifications before introducing promotion. Promotion must be idempotent, preserve - WIP/archive/audit rules, derive status from the real child, and never delete a child when its - thinking layer is removed. Do not call destructive deletion an undo. +1. **Dependency connections after linked steps.** Explicit saved-step promotion now stages card, + board concurrency token and audit through the shared guarded writer; one atomic deck CAS commits + them before realtime notification. Repeated promotion reuses the link, import remaps relationships, + and removing thinking retains cards. Status is fetched from the real card on load/explicit refresh. + Next: separate dependency edges and their lifecycle; do not infer them from a thinking link. 2. **Contextual companion and proposal overlays.** Bind current board/card/selected source evidence; render the existing effective proposal revision in place and preserve the approve/apply separation. Integrate private memory retrieval only with explicit user scope and archived exclusion. @@ -67,9 +68,37 @@ counts are not used as evidence. Further implementation is tracked in 5. **Expanded intelligence and optional attention.** Build a usefulness corpus for new structural and model-generated observations, then prove freshness, permissions, deduplication, budget and dismissal. Optional nudges require a separate opt-in suppression policy; current insights never interrupt. -6. **Comparison beyond personal trials.** Record product build and scenario when trials span releases; - define outcomes, assignment and sample requirements before making statistical A/B claims. +6. **Comparison beyond personal trials.** Scenarios, explicit completion outcomes, optional ease ratings + and backend-reported version attribution now travel in version-2 exports. Next: independent participant + sampling/assignment and outcome analysis before statistical A/B claims. The version is not an exact + frontend commit fingerprint; unavailable attribution remains null. These are remaining prototype capabilities, not blockers hidden behind placeholder success states. Existing owner decisions remain in [OUTSTANDING_TASKS.md](../../OUTSTANDING_TASKS.md); no publisher, signing, private-instance, release/runner or subjective palette/dogfooding item is inferred complete. + + +## Linked-step continuation (2026-09-09) + +The next vertical under #2808 adds manual card promotion to saved thinking steps. Its direct checks +are `ThinkingStepApiTests` (authentication, viewer/foreign scope, WIP/archive/stale/invalid rejection, +concurrent saves, repeat safety, actor audit, deletion and portable links), the existing +`ThinkingDeckApiTests` and `CardServiceTests`, and `ThinkingStepCard.spec.ts`. The new journey in +`workspace-overhaul.spec.ts` exercises creation, retry, refreshed blocker status, all experiences, +phone width and automated accessibility. Exact execution results belong to the continuation PR. + +New links are server introduced only; general deck saves cannot inject a foreign card reference. +Import accepts optional source card IDs only to remap relationships within the imported payload. +Source IDs never become the IDs of newly created cards. Linked material advertises schema version 2 +to prevent silent link loss in older readers. API tests cover version-2 round trips, reject linked +material mislabeled as version 1, and verify ordinary decks still use version 1. +This uses existing JSON thinking storage, +with no migration. It does not introduce a dependency graph, private-memory retrieval or autonomy. + + +Comparison follow-through keeps observations session-only and explicit. `workspaceExperimentStore.spec.ts` +and the comparison browser journey cover scenario/outcome validation, optional ratings, versioned export +and identity reset. The insight/memory follow-through uses delayed-response component regressions to +prove that analysis settles across route changes, conflicting actions are disabled, initial memory reads +finish before creation, and Retry returns to failed board discovery. These are fixes from #2808's +recorded review residuals, not changes to approval/apply authority. diff --git a/frontend/taskdeck-web/src/api/thinkingApi.ts b/frontend/taskdeck-web/src/api/thinkingApi.ts index cb101af16b..777627e55b 100644 --- a/frontend/taskdeck-web/src/api/thinkingApi.ts +++ b/frontend/taskdeck-web/src/api/thinkingApi.ts @@ -3,6 +3,9 @@ import type { ThinkingDeck, ThinkingLayer } from '../types/thinking' import type { Memory, MemoryStatus } from '../types/workspaceInsights' export const thinkingApi = { + async promote(boardId: string, cardId: string, layerId: string, itemId: string, expectedRevision: number, columnId: string, title: string): Promise { + return (await http.post(`/boards/${boardId}/cards/${cardId}/thinking/steps/${layerId}/${itemId}/card`, { expectedRevision, columnId, title })).data + }, async getAnswer(boardId: string, cardId: string, layerId: string): Promise { const { data } = await http.get(`/boards/${boardId}/cards/${cardId}/thinking/questions/${layerId}/answer`) return data || null diff --git a/frontend/taskdeck-web/src/components/board/CardModal.vue b/frontend/taskdeck-web/src/components/board/CardModal.vue index 5c1e5b759a..6f540fde0e 100644 --- a/frontend/taskdeck-web/src/components/board/CardModal.vue +++ b/frontend/taskdeck-web/src/components/board/CardModal.vue @@ -21,8 +21,12 @@ const props = withDefaults(defineProps<{ isOpen: boolean labels: Label[] presentation?: 'modal' | 'inspector' + suppressDiscardPrompt?: boolean + skipFocusRestore?: boolean }>(), { presentation: 'modal', + suppressDiscardPrompt: false, + skipFocusRestore: false, }) const emit = defineEmits<{ @@ -113,7 +117,11 @@ watch( await nextTick() focusInitialControl() } else if (wasOpen) { - restoreFocus() + if (props.skipFocusRestore) { + previouslyFocusedElement = null + } else { + restoreFocus() + } } }, { immediate: true }, @@ -142,7 +150,7 @@ watch( ) onUnmounted(() => { - if (props.isOpen) { + if (props.isOpen && !props.skipFocusRestore) { restoreFocus() } }) @@ -234,7 +242,16 @@ watch(hasUnsavedChanges, (dirty) => { emit('dirty-change', dirty) }, { immediate: true }) +watch(() => props.suppressDiscardPrompt, (suppress) => { + if (!suppress) return + + pendingThinkingPath.value = null + showDiscardConfirm.value = false +}, { immediate: true }) + function handleClose() { + if (props.suppressDiscardPrompt) return + if (hasUnsavedChanges.value) { showDiscardConfirm.value = true return @@ -245,6 +262,7 @@ function handleClose() { useEscapeToClose( () => props.isOpen && + !props.suppressDiscardPrompt && !showDiscardConfirm.value && !showDeleteConfirm.value && !showCommentDeleteConfirm.value, diff --git a/frontend/taskdeck-web/src/components/paper/PaperCommandPalette.vue b/frontend/taskdeck-web/src/components/paper/PaperCommandPalette.vue index df111728be..a5d61877cf 100644 --- a/frontend/taskdeck-web/src/components/paper/PaperCommandPalette.vue +++ b/frontend/taskdeck-web/src/components/paper/PaperCommandPalette.vue @@ -165,7 +165,7 @@ function handleClose() { } function isFocusableElement(element: HTMLElement | null): element is HTMLElement { - if (!element?.isConnected || !element.matches(focusableSelector)) return false + if (!element?.isConnected || !element.matches(`${focusableSelector}, [tabindex="-1"]:not(:disabled)`)) return false if (element.closest('[hidden], [aria-hidden="true"], [inert]')) return false return true } diff --git a/frontend/taskdeck-web/src/components/thinking/ThinkingDeckPanel.vue b/frontend/taskdeck-web/src/components/thinking/ThinkingDeckPanel.vue index 301faf2d92..cccb0bf967 100644 --- a/frontend/taskdeck-web/src/components/thinking/ThinkingDeckPanel.vue +++ b/frontend/taskdeck-web/src/components/thinking/ThinkingDeckPanel.vue @@ -1,22 +1,25 @@ + + + + diff --git a/frontend/taskdeck-web/src/composables/useThinkingDeck.ts b/frontend/taskdeck-web/src/composables/useThinkingDeck.ts index 3ca487eede..cab2dfb19e 100644 --- a/frontend/taskdeck-web/src/composables/useThinkingDeck.ts +++ b/frontend/taskdeck-web/src/composables/useThinkingDeck.ts @@ -1,6 +1,6 @@ import { computed, ref, watch, type Ref } from 'vue' import { thinkingApi } from '../api/thinkingApi' -import type { ThinkingKind, ThinkingLayer } from '../types/thinking' +import type { ThinkingDeck, ThinkingKind, ThinkingLayer } from '../types/thinking' export function useThinkingDeck(boardId: Ref, cardId: Ref) { const layers = ref([]) @@ -62,6 +62,11 @@ export function useThinkingDeck(boardId: Ref, cardId: Ref) { if (layers.value.length >= 40) return layers.value.push({ id: crypto.randomUUID(), kind, title: '', body: '', items: [], selectedOptionId: null }) } + function acceptPromotion(deck: ThinkingDeck) { + layers.value = deck.layers + revision.value = deck.revision + baseline.value = JSON.stringify(deck.layers) + } function move(index: number, offset: number) { const target = index + offset if (target < 0 || target >= layers.value.length) return @@ -69,5 +74,5 @@ export function useThinkingDeck(boardId: Ref, cardId: Ref) { if (layer) layers.value.splice(target, 0, layer) } watch([boardId, cardId], () => { layers.value = []; baseline.value = '[]'; void load() }, { immediate: true }) - return { layers, revision, loading, saving, ready, canWrite, error, conflict, dirty, load, save, add, move } + return { layers, revision, loading, saving, ready, canWrite, error, conflict, dirty, load, save, add, move, acceptPromotion } } diff --git a/frontend/taskdeck-web/src/store/workspaceExperimentStore.ts b/frontend/taskdeck-web/src/store/workspaceExperimentStore.ts index 748d6fc5e3..540c1393a1 100644 --- a/frontend/taskdeck-web/src/store/workspaceExperimentStore.ts +++ b/frontend/taskdeck-web/src/store/workspaceExperimentStore.ts @@ -6,28 +6,61 @@ export interface WorkspaceTrial { experience: string presentation: string theme: string + build: string | null + scenario: WorkspaceScenario + completionOutcome: WorkspaceCompletionOutcome recordedAt: string - ease: number + ease: number | null note: string } +export const WORKSPACE_COMPARISON_SCENARIOS = [ + { id: 'capture-review-board', label: 'Capture → Review → Board' }, + { id: 'resume-thinking', label: 'Resume a card and leave a next step' }, + { id: 'insight-memory', label: 'Check insights and answer a Memory question' }, +] as const + +export type WorkspaceScenario = typeof WORKSPACE_COMPARISON_SCENARIOS[number]['id'] + +export const WORKSPACE_COMPLETION_OUTCOMES = [ + { id: 'completed', label: 'Completed the scenario' }, + { id: 'stopped', label: 'Stopped with a next step' }, + { id: 'blocked', label: 'Reached a blocker' }, + { id: 'not-completed', label: 'Did not complete the scenario' }, +] as const + +export type WorkspaceCompletionOutcome = typeof WORKSPACE_COMPLETION_OUTCOMES[number]['id'] + +export type WorkspaceTrialInput = Omit & { build?: string | null } + +function isScenario(value: unknown): value is WorkspaceScenario { + return WORKSPACE_COMPARISON_SCENARIOS.some(scenario => scenario.id === value) +} + +function isCompletionOutcome(value: unknown): value is WorkspaceCompletionOutcome { + return WORKSPACE_COMPLETION_OUTCOMES.some(outcome => outcome.id === value) +} + /** Deliberately session-only. No automatic assignment, telemetry or persisted work content. */ export const useWorkspaceExperimentStore = defineStore('workspaceExperiment', () => { const session = useSessionStore() const trials = ref([]) watch(() => session.userId, () => { trials.value = [] }, { flush: 'sync' }) - function record(trial: Omit) { - if (!session.userId || !Number.isInteger(trial.ease) || trial.ease < 1 || trial.ease > 5) return false + function record(trial: WorkspaceTrialInput) { + const ease = trial.ease ?? null + if (!session.userId || !isScenario(trial.scenario) || !isCompletionOutcome(trial.completionOutcome)) return false + if (ease !== null && (!Number.isInteger(ease) || ease < 1 || ease > 5)) return false if (!['classic', 'studio', 'companion', 'unified'].includes(trial.experience) || !['zen', 'studio', 'control'].includes(trial.presentation)) return false - trials.value.push({ ...trial, note: trial.note.slice(0, 2000), recordedAt: new Date().toISOString() }) + const build = typeof trial.build === 'string' && trial.build.trim() ? trial.build.trim().slice(0, 256) : null + trials.value.push({ ...trial, build, ease, note: trial.note.slice(0, 2000), recordedAt: new Date().toISOString() }) return true } function clear() { trials.value = [] } function exportJson() { - return JSON.stringify({ kind: 'taskdeck-workspace-comparison', version: 1, trials: trials.value }, null, 2) + return JSON.stringify({ kind: 'taskdeck-workspace-comparison', version: 2, trials: trials.value }, null, 2) } return { trials, record, clear, exportJson } diff --git a/frontend/taskdeck-web/src/tests/components/CardModal.spec.ts b/frontend/taskdeck-web/src/tests/components/CardModal.spec.ts index 42c55cbc83..89d01678ff 100644 --- a/frontend/taskdeck-web/src/tests/components/CardModal.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/CardModal.spec.ts @@ -477,6 +477,30 @@ describe('CardModal', () => { wrapper.unmount() }) + it('hands discard confirmation to the parent without reopening a stale local prompt', async () => { + const wrapper = mount(CardModal, { + props: { card, isOpen: true, labels, presentation: 'inspector' }, + attachTo: document.body, + }) + await nextTick() + + await wrapper.get('#card-title').setValue('Unsaved title') + await wrapper.get('[aria-label="Close card editor"]').trigger('click') + await nextTick() + expect(document.body.querySelector('[data-testid="card-discard-confirm"]')).not.toBeNull() + + await wrapper.setProps({ suppressDiscardPrompt: true }) + await nextTick() + expect(document.body.querySelector('[data-testid="card-discard-confirm"]')).toBeNull() + + await wrapper.setProps({ suppressDiscardPrompt: false }) + await nextTick() + expect(document.body.querySelector('[data-testid="card-discard-confirm"]')).toBeNull() + expect(wrapper.emitted('close')).toBeUndefined() + + wrapper.unmount() + }) + it('should not render when isOpen is false', () => { const wrapper = mount(CardModal, { props: { diff --git a/frontend/taskdeck-web/src/tests/components/ThinkingStepCard.spec.ts b/frontend/taskdeck-web/src/tests/components/ThinkingStepCard.spec.ts new file mode 100644 index 0000000000..076420dd49 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/ThinkingStepCard.spec.ts @@ -0,0 +1,68 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ThinkingStepCard from '../../components/thinking/ThinkingStepCard.vue' +import { boardsApi } from '../../api/boardsApi' +import { cardsApi } from '../../api/cardsApi' +import { thinkingApi } from '../../api/thinkingApi' +import type { BoardDetail, Card } from '../../types/board' + +vi.mock('../../api/boardsApi', () => ({ boardsApi: { getBoard: vi.fn() } })) +vi.mock('../../api/cardsApi', () => ({ cardsApi: { getCards: vi.fn() } })) +vi.mock('../../api/thinkingApi', () => ({ thinkingApi: { promote: vi.fn() } })) +const props = { boardId: 'board', cardId: 'parent', layerId: 'layer', item: { id: 'step', text: 'A useful step', completed: false }, revision: 3, sourceReady: true, canWrite: true } +const board = { id: 'board', isArchived: false, columns: [{ id: 'todo', name: 'To do' }, { id: 'done', name: 'Done' }] } as BoardDetail +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(boardsApi.getBoard).mockResolvedValue(board) + vi.mocked(cardsApi.getCards).mockResolvedValue([]) +}) +const mountStep = (overrides = {}) => mount(ThinkingStepCard, { props: { ...props, ...overrides }, global: { stubs: { RouterLink: { template: '' } } } }) +describe('ThinkingStepCard', () => { + it('requires saved source and an explicit destination before submitting the revision', async () => { + const wrapper = mountStep({ sourceReady: false }) + expect(wrapper.get('button').attributes('disabled')).toBeDefined() + expect(boardsApi.getBoard).not.toHaveBeenCalled() + await wrapper.setProps({ sourceReady: true }) + await wrapper.get('button').trigger('click') + await flushPromises() + const create = () => wrapper.findAll('button').find(button => button.text() === 'Create linked card')! + expect(create().attributes('disabled')).toBeDefined() + await wrapper.get('select').setValue('todo') + vi.mocked(thinkingApi.promote).mockResolvedValue({ cardId: 'parent', revision: 4, schemaVersion: 1, canWrite: true, layers: [] }) + await create().trigger('click') + await flushPromises() + expect(thinkingApi.promote).toHaveBeenCalledWith('board', 'parent', 'layer', 'step', 3, 'todo', 'A useful step') + expect(wrapper.emitted('promoted')).toHaveLength(1) + expect(wrapper.emitted('busy')).toEqual([[true], [false]]) + expect(wrapper.emitted('dirty-change')).toEqual([[true], [false]]) + }) + it('retains choices on refusal and does not replace the deck', async () => { + const wrapper = mountStep() + await wrapper.get('button').trigger('click'); await flushPromises() + await wrapper.get('input').setValue('Keep my title') + await wrapper.get('select').setValue('todo') + vi.mocked(thinkingApi.promote).mockRejectedValue(new Error('offline')) + await wrapper.findAll('button').find(button => button.text() === 'Create linked card')!.trigger('click') + await flushPromises() + expect(wrapper.find('[role="alert"]').exists()).toBe(true) + expect((wrapper.get('input').element as HTMLInputElement).value).toBe('Keep my title') + expect((wrapper.get('select').element as HTMLSelectElement).value).toBe('todo') + expect(wrapper.emitted('promoted')).toBeUndefined() + }) + it('reads real column and blocker status for viewers, refreshes, and never offers duplicate creation', async () => { + vi.mocked(cardsApi.getCards).mockResolvedValue([{ id: 'child', title: 'Real child', columnId: 'todo', isBlocked: true } as Card]) + const wrapper = mountStep({ item: { ...props.item, linkedCardId: 'child' }, canWrite: false }) + await flushPromises() + expect(wrapper.text()).toContain('To do · Blocked') + expect(wrapper.text()).not.toContain('Create card from step') + vi.mocked(cardsApi.getCards).mockResolvedValue([{ id: 'child', title: 'Renamed child', columnId: 'done', isBlocked: false } as Card]) + await wrapper.get('button').trigger('click'); await flushPromises() + expect(wrapper.text()).toContain('Renamed child') + expect(wrapper.text()).toContain('Done') + expect(wrapper.text()).not.toContain('Blocked') + vi.mocked(cardsApi.getCards).mockResolvedValue([]) + await wrapper.get('button').trigger('click'); await flushPromises() + expect(wrapper.text()).toContain('no longer available') + expect(thinkingApi.promote).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/taskdeck-web/src/tests/components/paper/PaperCommandPalette.spec.ts b/frontend/taskdeck-web/src/tests/components/paper/PaperCommandPalette.spec.ts index f23b7a86ff..5b781f9e6e 100644 --- a/frontend/taskdeck-web/src/tests/components/paper/PaperCommandPalette.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/paper/PaperCommandPalette.spec.ts @@ -111,9 +111,9 @@ describe('PaperCommandPalette', () => { expect(backdrop()).toBeNull() }) - it('returns focus to the connected opener when it closes', async () => { - const opener = document.createElement('button') - opener.type = 'button' + it.each(['button', 'div'] as const)('returns focus to the connected %s opener when it closes', async (tagName) => { + const opener = document.createElement(tagName) + if (tagName === 'div') opener.tabIndex = -1 opener.textContent = 'Open palette' document.body.append(opener) opener.focus() diff --git a/frontend/taskdeck-web/src/tests/store/workspaceExperimentStore.spec.ts b/frontend/taskdeck-web/src/tests/store/workspaceExperimentStore.spec.ts index 8e8e4dd95a..9cc93296ca 100644 --- a/frontend/taskdeck-web/src/tests/store/workspaceExperimentStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/workspaceExperimentStore.spec.ts @@ -5,16 +5,32 @@ import { useWorkspaceExperimentStore } from '../../store/workspaceExperimentStor const session = reactive({ userId: 'first' as string | null }) vi.mock('../../store/sessionStore', () => ({ useSessionStore: () => session })) -const trial = { experience: 'studio', presentation: 'zen', theme: 'grove', ease: 4, note: 'I found the next step.' } +const trial = { + experience: 'studio', + presentation: 'zen', + theme: 'grove', + build: 'v0.3.0', + scenario: 'capture-review-board' as const, + completionOutcome: 'completed' as const, + ease: 4, + note: 'I found the next step.', +} describe('workspace comparison observations', () => { beforeEach(() => { setActivePinia(createPinia()); session.userId = 'first'; localStorage.clear() }) it('exports only manually recorded trials and keeps notes out of browser storage', () => { const store = useWorkspaceExperimentStore() expect(store.record(trial)).toBe(true) - expect(JSON.parse(store.exportJson()).trials[0]).toMatchObject(trial) + const exported = JSON.parse(store.exportJson()) + expect(exported.version).toBe(2) + expect(exported.trials[0]).toMatchObject(trial) expect(localStorage.length).toBe(0) }) + it('keeps an unselected ease rating unobserved and accepts an unavailable build', () => { + const store = useWorkspaceExperimentStore() + expect(store.record({ ...trial, build: null, ease: null })).toBe(true) + expect(store.trials[0]).toMatchObject({ build: null, ease: null }) + }) it('clears observations immediately on identity change or sign out', () => { const store = useWorkspaceExperimentStore() store.record(trial) @@ -30,6 +46,8 @@ describe('workspace comparison observations', () => { expect(store.record({ ...trial, ease: 0 })).toBe(false) expect(store.record({ ...trial, ease: 4.5 })).toBe(false) expect(store.record({ ...trial, experience: 'automatic' })).toBe(false) + expect(store.record({ ...trial, scenario: 'free-form' as never })).toBe(false) + expect(store.record({ ...trial, completionOutcome: 'assumed' as never })).toBe(false) expect(store.trials).toEqual([]) }) }) diff --git a/frontend/taskdeck-web/src/tests/views/QuietInsightsView.spec.ts b/frontend/taskdeck-web/src/tests/views/QuietInsightsView.spec.ts index 60864ad5d1..4b613aec73 100644 --- a/frontend/taskdeck-web/src/tests/views/QuietInsightsView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/QuietInsightsView.spec.ts @@ -1,10 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { reactive } from 'vue' import QuietInsightsView from '../../views/QuietInsightsView.vue' import type { Board } from '../../types/board' import type { Insight, Memory } from '../../types/workspaceInsights' +enableAutoUnmount(afterEach) + const mocks = vi.hoisted(() => ({ route: { query: {} as Record }, boardStore: { boards: [] as Board[], fetchBoards: vi.fn() }, @@ -23,7 +25,7 @@ const api = mocks.api vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), - useRoute: () => mocks.route, + useRoute: () => routeMock, RouterLink: { props: ['to'], template: '', @@ -31,7 +33,7 @@ vi.mock('vue-router', () => ({ })) vi.mock('../../store/boardStore', () => ({ - useBoardStore: () => mocks.boardStore, + useBoardStore: () => boardStore, })) vi.mock('../../api/workspaceInsights', () => ({ @@ -103,7 +105,7 @@ describe('QuietInsightsView', () => { wrapper.unmount() }) beforeEach(() => { - vi.clearAllMocks() + vi.resetAllMocks() routeMock.query = {} boardStore.boards = [board] boardStore.fetchBoards.mockResolvedValue(undefined) @@ -113,6 +115,41 @@ describe('QuietInsightsView', () => { api.answerInsight.mockResolvedValue(memory) }) + it('prevents overlapping actions during analysis and settles after a board change', async () => { + let finish!: (value: Insight[]) => void + api.analyzeBoard.mockReturnValue(new Promise(resolve => { finish = resolve })) + const wrapper = mountView() + await settle() + await wrapper.get('[data-action="analyze-insights"]').trigger('click') + expect(wrapper.get('[data-action="dismiss-insight"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('[data-action="answer-insight"]').attributes('disabled')).toBeDefined() + await wrapper.get('[data-action="dismiss-insight"]').trigger('click') + expect(api.updateInsight).not.toHaveBeenCalled() + boardStore.boards = [board, { ...board, id: 'board-2' }] + routeMock.query = { boardId: 'board-2' } + await settle() + finish([insight]) + await settle() + expect(wrapper.get('[data-action="analyze-insights"]').attributes('disabled')).toBeUndefined() + expect(api.getInsights).toHaveBeenLastCalledWith('board-2') + wrapper.unmount() + }) + + it('retries board discovery after a linked-board transition fails', async () => { + const wrapper = mountView() + await settle() + boardStore.boards = [board, { ...board, id: 'board-2' }] + boardStore.fetchBoards.mockRejectedValueOnce(new Error('Board discovery unavailable')) + routeMock.query = { boardId: 'board-2' } + await settle() + expect(wrapper.find('[role="alert"]').text()).toContain('Board discovery unavailable') + await wrapper.find('[role="alert"] button').trigger('click') + await settle() + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + expect(api.getInsights).toHaveBeenLastCalledWith('board-2') + wrapper.unmount() + }) + it('loads the selected board and shows structural evidence', async () => { const wrapper = mountView() await settle() diff --git a/frontend/taskdeck-web/src/tests/views/WorkspaceMemoryView.spec.ts b/frontend/taskdeck-web/src/tests/views/WorkspaceMemoryView.spec.ts index 1a1100114b..edd53a2222 100644 --- a/frontend/taskdeck-web/src/tests/views/WorkspaceMemoryView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/WorkspaceMemoryView.spec.ts @@ -1,10 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { reactive } from 'vue' import WorkspaceMemoryView from '../../views/WorkspaceMemoryView.vue' import type { Board } from '../../types/board' import type { Memory } from '../../types/workspaceInsights' +enableAutoUnmount(afterEach) + const mocks = vi.hoisted(() => ({ route: { query: {} as Record }, boardStore: { boards: [] as Board[], fetchBoards: vi.fn() }, @@ -23,7 +25,7 @@ const api = mocks.api vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), - useRoute: () => mocks.route, + useRoute: () => routeMock, RouterLink: { props: ['to'], template: '', @@ -31,7 +33,7 @@ vi.mock('vue-router', () => ({ })) vi.mock('../../store/boardStore', () => ({ - useBoardStore: () => mocks.boardStore, + useBoardStore: () => boardStore, })) vi.mock('../../api/workspaceInsights', () => ({ @@ -104,7 +106,7 @@ describe('WorkspaceMemoryView', () => { }) beforeEach(() => { - vi.clearAllMocks() + vi.resetAllMocks() routeMock.query = {} boardStore.boards = [board] boardStore.fetchBoards.mockResolvedValue(undefined) @@ -168,6 +170,35 @@ describe('WorkspaceMemoryView', () => { expect(wrapper.text()).toContain('No memory yet') }) + it('waits for the initial list before allowing a new memory', async () => { + let finish!: (value: Memory[]) => void + api.getMemories.mockReturnValueOnce(new Promise(resolve => { finish = resolve })) + const wrapper = mount(WorkspaceMemoryView) + await settle() + expect(wrapper.get('[data-action="new-memory"]').attributes('disabled')).toBeDefined() + await wrapper.get('[data-action="new-memory"]').trigger('click') + expect(wrapper.find('form').exists()).toBe(false) + finish([memory]) + await settle() + expect(wrapper.get('[data-action="new-memory"]').attributes('disabled')).toBeUndefined() + wrapper.unmount() + }) + + it('retries board discovery after a linked-board transition fails', async () => { + const wrapper = mount(WorkspaceMemoryView) + await settle() + boardStore.boards = [board, { ...board, id: 'board-2' }] + boardStore.fetchBoards.mockRejectedValueOnce(new Error('Board discovery unavailable')) + routeMock.query = { boardId: 'board-2' } + await settle() + expect(wrapper.find('[role="alert"]').text()).toContain('Board discovery unavailable') + await wrapper.find('[role="alert"] button').trigger('click') + await settle() + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + expect(api.getMemories).toHaveBeenLastCalledWith('board-2', false) + wrapper.unmount() + }) + it('keeps board mutation out of the memory copy', async () => { const wrapper = mount(WorkspaceMemoryView) await settle() diff --git a/frontend/taskdeck-web/src/tests/views/paper/PaperBoardView.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/PaperBoardView.spec.ts index aadbd0b679..90a5c5aa2b 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/PaperBoardView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/PaperBoardView.spec.ts @@ -133,7 +133,7 @@ function mountView(props: Record = {}) { stubs: { CardModal: { name: 'CardModal', - props: ['card', 'isOpen', 'labels', 'presentation'], + props: ['card', 'isOpen', 'labels', 'presentation', 'suppressDiscardPrompt', 'skipFocusRestore'], emits: ['dirty-change', 'updated', 'close'], template: '
{{ card.title }}
', }, @@ -369,6 +369,30 @@ describe('PaperBoardView', () => { expect(wrapper.find('[data-testid="paper-card-modal"]').exists()).toBe(false) }) + it('lets the parent own the single confirmation when route navigation supersedes card close', async () => { + const wrapper = mountView() + await openDirtyCard(wrapper, cardsByColumn.get('col-backlog')![0]!) + const modal = wrapper.findComponent({ name: 'CardModal' }) + + const cancelledNavigation = routeLeaveGuard!() + await nextTick() + expect(modal.props('suppressDiscardPrompt')).toBe(true) + expect(wrapper.findAll('[role="dialog"]')).toHaveLength(1) + + await wrapper.get('[data-testid="card-switch-cancel"]').trigger('click') + await expect(cancelledNavigation).resolves.toBe(false) + await nextTick() + expect(modal.props('suppressDiscardPrompt')).toBe(false) + expect(wrapper.get('[data-testid="paper-card-modal"]').text()).toContain('A') + + const confirmedNavigation = routeUpdateGuard!() + await nextTick() + expect(modal.props('suppressDiscardPrompt')).toBe(true) + await wrapper.get('[data-testid="card-switch-confirm"]').trigger('click') + await expect(confirmedNavigation).resolves.toBe(true) + expect(wrapper.find('[data-testid="paper-card-modal"]').exists()).toBe(false) + }) + it('guards reused board-route changes while the inspector is dirty', async () => { const wrapper = mountView() wrapper.findAllComponents(PaperBoardColumn)[0]!.vm.$emit( diff --git a/frontend/taskdeck-web/src/types/thinking.ts b/frontend/taskdeck-web/src/types/thinking.ts index 31b35bb189..05bee5abcf 100644 --- a/frontend/taskdeck-web/src/types/thinking.ts +++ b/frontend/taskdeck-web/src/types/thinking.ts @@ -1,5 +1,5 @@ export type ThinkingKind = 'note' | 'question' | 'options' | 'steps' | 'thread' -export interface ThinkingItem { id: string; text: string; completed: boolean } +export interface ThinkingItem { id: string; text: string; completed: boolean; linkedCardId?: string | null } export interface ThinkingLayer { id: string kind: ThinkingKind diff --git a/frontend/taskdeck-web/src/views/QuietInsightsView.vue b/frontend/taskdeck-web/src/views/QuietInsightsView.vue index e7960f4309..244333bbd0 100644 --- a/frontend/taskdeck-web/src/views/QuietInsightsView.vue +++ b/frontend/taskdeck-web/src/views/QuietInsightsView.vue @@ -143,7 +143,7 @@ async function loadInsights() { } async function analyzeBoard() { - if (!selectedBoardId.value || analyzing.value || answeringInsightId.value || busyInsightIds.value.size > 0) return + if (!selectedBoardId.value || loading.value || analyzing.value || answeringInsightId.value || busyInsightIds.value.size > 0) return const boardId = selectedBoardId.value const generation = ++insightsRequestGeneration analyzing.value = true @@ -158,12 +158,13 @@ async function analyzeBoard() { error.value = errorMessage(value, 'Unable to analyze this board.') } } finally { - if (generation === insightsRequestGeneration) analyzing.value = false + // Only one analysis can run; a route/read generation change must still settle it. + analyzing.value = false } } async function applyAction(insight: Insight, action: InsightAction) { - if (isBusy(insight.id) || answeringInsightId.value) return + if (analyzing.value || loading.value || isBusy(insight.id) || answeringInsightId.value) return const boardId = selectedBoardId.value setBusy(insight.id, true) cardErrors.value = { ...cardErrors.value, [insight.id]: '' } @@ -182,7 +183,7 @@ async function applyAction(insight: Insight, action: InsightAction) { } function openAnswer(insight: Insight) { - if (answeringInsightId.value || busyInsightIds.value.size > 0) return + if (analyzing.value || loading.value || answeringInsightId.value || busyInsightIds.value.size > 0) return answeringInsightId.value = insight.id answerText.value = '' answerStatus.value = 'statement' @@ -222,8 +223,8 @@ async function answerInsight(insight: Insight) { } function retry() { - if (selectedBoardId.value) void loadInsights() - else void loadBoards() + if (boardError.value || !selectedBoardId.value) void loadBoards() + else void loadInsights() } onMounted(async () => { @@ -349,7 +350,7 @@ watch(queryBoardId, () => { :key="action" :data-action="`${action}-insight`" variant="ghost" - :disabled="isBusy(insight.id) || Boolean(answeringInsightId)" + :disabled="analyzing || loading || isBusy(insight.id) || Boolean(answeringInsightId)" @click="applyAction(insight, action)" > {{ actionLabel(action) }} @@ -358,7 +359,7 @@ watch(queryBoardId, () => { v-if="insight.state === 'available'" data-action="answer-insight" variant="primary" - :disabled="isBusy(insight.id) || Boolean(answeringInsightId) || busyInsightIds.size > 0" + :disabled="analyzing || loading || isBusy(insight.id) || Boolean(answeringInsightId) || busyInsightIds.size > 0" @click="openAnswer(insight)" > Answer privately diff --git a/frontend/taskdeck-web/src/views/WorkspaceMemoryView.vue b/frontend/taskdeck-web/src/views/WorkspaceMemoryView.vue index 31fe7c7c9f..25d1e01060 100644 --- a/frontend/taskdeck-web/src/views/WorkspaceMemoryView.vue +++ b/frontend/taskdeck-web/src/views/WorkspaceMemoryView.vue @@ -134,6 +134,7 @@ async function loadMemories() { const { leaveRequested, decide } = useUnsavedWorkspaceNavigation(() => editorDirty.value || saving.value) function openCreate() { + if (loading.value || boardLoading.value || boardError.value) return editingId.value = null formTitle.value = '' formText.value = '' @@ -169,7 +170,7 @@ function requestCloseEditor() { async function saveMemory() { const title = formTitle.value.trim() const text = formText.value.trim() - if (!selectedBoardId.value || !title || !text || saving.value) return + if (!selectedBoardId.value || loading.value || boardLoading.value || boardError.value || !title || !text || saving.value) return saving.value = true formError.value = null @@ -226,8 +227,8 @@ async function toggleArchived(memory: Memory) { } function retry() { - if (selectedBoardId.value) void loadMemories() - else void loadBoards() + if (boardError.value || !selectedBoardId.value) void loadBoards() + else void loadMemories() } onMounted(async () => { @@ -272,7 +273,7 @@ watch(queryBoardId, () => { Show archived {{ selectedBoard.name }} - + Add memory diff --git a/frontend/taskdeck-web/src/views/overhaul/WorkspaceExperiencesView.vue b/frontend/taskdeck-web/src/views/overhaul/WorkspaceExperiencesView.vue index e2fe2c1569..562055ee43 100644 --- a/frontend/taskdeck-web/src/views/overhaul/WorkspaceExperiencesView.vue +++ b/frontend/taskdeck-web/src/views/overhaul/WorkspaceExperiencesView.vue @@ -1,13 +1,24 @@