From 8fd0bc67ca2a0a893592e42aec0fb28dcf93519b Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 10 Sep 2026 02:20:38 +0100 Subject: [PATCH 1/2] Preserve oversized audio upload errors after storage rollback --- .../Controllers/ThinkingAudioController.cs | 14 ++++- .../ThinkingAudioApiTests.cs | 51 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/backend/src/Taskdeck.Api/Controllers/ThinkingAudioController.cs b/backend/src/Taskdeck.Api/Controllers/ThinkingAudioController.cs index 97dd6c6b5d..b2359ce07e 100644 --- a/backend/src/Taskdeck.Api/Controllers/ThinkingAudioController.cs +++ b/backend/src/Taskdeck.Api/Controllers/ThinkingAudioController.cs @@ -4,6 +4,8 @@ using Taskdeck.Application.DTOs; using Taskdeck.Application.Interfaces; using Taskdeck.Application.Services; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; namespace Taskdeck.Api.Controllers; @@ -55,8 +57,16 @@ public async Task Get(Guid boardId, Guid cardId, Guid layerId, Ca public async Task Upload(Guid boardId, Guid cardId, Guid layerId, [FromQuery] ThinkingAudioUploadDto dto, CancellationToken ct) { if (!TryGetCurrentUserId(out var userId, out var error)) return error!; - var result = await service.UploadAsync(userId, boardId, cardId, layerId, dto, Request.ContentType ?? "", Request.Body, ct); - return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); + try + { + var result = await service.UploadAsync(userId, boardId, cardId, layerId, dto, Request.ContentType ?? "", Request.Body, ct); + return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); + } + catch (BadHttpRequestException exception) when (exception.StatusCode == StatusCodes.Status413PayloadTooLarge) + { + return Result.Failure(ErrorCodes.PayloadTooLarge, + $"Recording exceeds the {ThinkingAudioService.MaximumBytes}-byte size limit").ToErrorActionResult(); + } } [HttpPut("{id:guid}/written-version")] public async Task Write(Guid id, ThinkingAudioWriteDto dto, CancellationToken ct) diff --git a/backend/tests/Taskdeck.Api.Tests/ThinkingAudioApiTests.cs b/backend/tests/Taskdeck.Api.Tests/ThinkingAudioApiTests.cs index 9e0b297c54..6ae8ddbef8 100644 --- a/backend/tests/Taskdeck.Api.Tests/ThinkingAudioApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ThinkingAudioApiTests.cs @@ -3,10 +3,18 @@ using System.Net.Http.Json; using FluentAssertions; using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; +using Moq; +using Taskdeck.Api.Controllers; +using Taskdeck.Api.Contracts; using Taskdeck.Api.Tests.Support; using Taskdeck.Application.DTOs; using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Exceptions; using Taskdeck.Domain.Entities; using Taskdeck.Domain.Enums; using Taskdeck.Infrastructure.Persistence; @@ -16,6 +24,49 @@ namespace Taskdeck.Api.Tests; public sealed class ThinkingAudioApiTests(TestWebApplicationFactory factory) : IClassFixture { + [Fact] + public async Task HostRejectedOversizedBodyKeeps413AndRollsBackOriginalStorage() + { + var (client, user, board, card, question) = await Setup(); + using (var scope = factory.Services.CreateScope()) + { + var actor = new Mock(); + actor.SetupGet(x => x.IsAuthenticated).Returns(true); + actor.SetupGet(x => x.UserId).Returns(user.ToString()); + var context = new DefaultHttpContext(); + context.Request.ContentType = "audio/wav"; + context.Request.Body = new HostRejectedAudioStream(Audio()); + var controller = new ThinkingAudioController(scope.ServiceProvider.GetRequiredService(), actor.Object) + { ControllerContext = new ControllerContext { HttpContext = context } }; + var result = await controller.Upload(board, card, question.Id, + new ThinkingAudioUploadDto(Guid.NewGuid(), 1, 70000, "original.wav"), default); + var response = result.Should().BeOfType().Subject; + response.StatusCode.Should().Be(StatusCodes.Status413PayloadTooLarge); + response.Value.Should().BeOfType().Which.ErrorCode.Should().Be(ErrorCodes.PayloadTooLarge); + } + using var verify = factory.Services.CreateScope(); + var db = verify.ServiceProvider.GetRequiredService(); + (await db.StoredBlobs.AnyAsync(x => x.OwnerUserId == user)).Should().BeFalse(); + (await db.StoredBlobReferences.AnyAsync(x => x.OwnerUserId == user)).Should().BeFalse(); + (await db.Captures.AnyAsync(x => x.UserId == user)).Should().BeFalse(); + (await db.ThinkingAudioAnswers.AnyAsync(x => x.UserId == user)).Should().BeFalse(); + (await db.LlmRequests.AnyAsync(x => x.UserId == user)).Should().BeFalse(); + (await client.GetAsync(Url(board, card, question.Id))).StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + private sealed class HostRejectedAudioStream(byte[] bytes) : MemoryStream(bytes) + { + private bool read; + public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) + { + if (read) throw new BadHttpRequestException("Request body too large", StatusCodes.Status413PayloadTooLarge); + read = true; + return base.ReadAsync(buffer[..Math.Min(buffer.Length, 32)], ct); + } + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) + => ReadAsync(buffer.AsMemory(offset, count), ct).AsTask(); + } + private async Task<(HttpClient Client, Guid User, Guid Board, Guid Card, ThinkingLayer Question)> Setup() { var client = factory.CreateClient(); var user = await ApiTestHarness.AuthenticateAsync(client, "audio-owner"); From df83c1cf331a40d5a364cd15e4147083c8ed7100 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 10 Sep 2026 02:26:39 +0100 Subject: [PATCH 2/2] Read original library metadata in one bounded query --- .../Interfaces/IThinkingAudioRepository.cs | 2 + .../Services/ThinkingAudioService.cs | 14 ++----- .../Repositories/ThinkingAudioRepository.cs | 19 ++++++++++ .../ThinkingAudioApiTests.cs | 37 ++++++++++++++++++- docs/IMPLEMENTATION_MASTERPLAN.md | 2 + docs/STATUS.md | 4 +- docs/product/WORKSPACE_OVERHAUL_VALIDATION.md | 6 +++ 7 files changed, 70 insertions(+), 14 deletions(-) diff --git a/backend/src/Taskdeck.Application/Interfaces/IThinkingAudioRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IThinkingAudioRepository.cs index f779f2d15a..19a3870ee5 100644 --- a/backend/src/Taskdeck.Application/Interfaces/IThinkingAudioRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/IThinkingAudioRepository.cs @@ -1,10 +1,12 @@ using Taskdeck.Domain.Entities; +using Taskdeck.Application.DTOs; namespace Taskdeck.Application.Interfaces; public interface IThinkingAudioRepository { Task> ListByUserAsync(Guid userId, int offset, int limit, CancellationToken ct); + Task> LibraryEntriesAsync(Guid userId, IReadOnlyCollection ids, CancellationToken ct); Task QuestionAsync(Guid userId, Guid cardId, Guid layerId, string hash, CancellationToken ct); Task GetAsync(Guid userId, Guid id, CancellationToken ct); Task UploadAsync(Guid userId, Guid uploadId, CancellationToken ct); diff --git a/backend/src/Taskdeck.Application/Services/ThinkingAudioService.cs b/backend/src/Taskdeck.Application/Services/ThinkingAudioService.cs index 8d52bbe1af..a2f6b14e98 100644 --- a/backend/src/Taskdeck.Application/Services/ThinkingAudioService.cs +++ b/backend/src/Taskdeck.Application/Services/ThinkingAudioService.cs @@ -26,17 +26,9 @@ public async Task> LibraryAsync(Guid userId, in var allowed = await authorization.GetReadableBoardIdsAsync(userId, candidates.Where(x => x.BoardId.HasValue).Select(x => x.BoardId!.Value), ct); if (!allowed.IsSuccess) return Result.Failure(allowed.ErrorCode, allowed.ErrorMessage); - var items = new List(); - foreach (var answer in candidates.Take(pageSize).Where(x => x.BoardId is null || allowed.Value.Contains(x.BoardId.Value))) - { - var capture = await captures.GetByIdForUserAsync(answer.CaptureId, userId, ct); - var asset = capture?.SourceAssets.SingleOrDefault(x => x.Id == answer.SourceAssetId); - if (asset is null) continue; - var evidence = capture!.SourceAssets.Single(x => x.Ordinal == 1).TextPayload!.Text; - items.Add(new(answer.Id, asset.OriginalName ?? "original-audio", asset.ByteSize, answer.CreatedAt, - evidence.Length > 500 ? evidence[..500] + "…" : evidence, answer.RepresentationId.HasValue, - answer.ConfirmedMemoryId.HasValue, answer.BoardId is null)); - } + var visibleIds = candidates.Take(pageSize).Where(x => x.BoardId is null || allowed.Value.Contains(x.BoardId.Value)) + .Select(x => x.Id).ToArray(); + var items = await answers.LibraryEntriesAsync(userId, visibleIds, ct); // Advance over the bounded owner page even when revoked boards hide every candidate. return Result.Success(new ThinkingAudioLibraryPage(items, candidates.Count > pageSize ? offset + pageSize : null)); } diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/ThinkingAudioRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/ThinkingAudioRepository.cs index f68059709e..34d8afe6f8 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/ThinkingAudioRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/ThinkingAudioRepository.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Taskdeck.Application.Interfaces; +using Taskdeck.Application.DTOs; using Taskdeck.Domain.Entities; using Taskdeck.Infrastructure.Persistence; @@ -7,6 +8,24 @@ namespace Taskdeck.Infrastructure.Repositories; public sealed class ThinkingAudioRepository(TaskdeckDbContext db) : IThinkingAudioRepository { + public async Task> LibraryEntriesAsync(Guid userId, IReadOnlyCollection ids, CancellationToken ct) + { + var pageIds = ids.Distinct().Take(20).ToArray(); + if (pageIds.Length == 0) return []; + // One owner-scoped projection: no capture graphs, full evidence, representations or blobs. + return await ( + from answer in db.ThinkingAudioAnswers.AsNoTracking() + join capture in db.Captures.AsNoTracking() on answer.CaptureId equals capture.Id + join asset in db.Set().AsNoTracking() on answer.SourceAssetId equals asset.Id + from evidence in db.Set().AsNoTracking().Where(source => source.CaptureId == capture.Id && source.Ordinal == 1 && source.TextPayload != null) + where answer.UserId == userId && capture.UserId == userId && pageIds.Contains(answer.Id) && asset.CaptureId == capture.Id + orderby answer.Id + select new ThinkingAudioLibraryEntry(answer.Id, asset.OriginalName ?? "original-audio", asset.ByteSize, + answer.CreatedAt, evidence.TextPayload!.Text.Length > 500 ? evidence.TextPayload.Text.Substring(0, 500) + "…" : evidence.TextPayload.Text, + answer.RepresentationId.HasValue, answer.ConfirmedMemoryId.HasValue, answer.BoardId == null) + ).ToListAsync(ct); + } + public async Task> ListByUserAsync(Guid userId, int offset, int limit, CancellationToken ct) => await db.ThinkingAudioAnswers.AsNoTracking().Where(x => x.UserId == userId) .OrderBy(x => x.Id).Skip(offset).Take(limit).ToListAsync(ct); diff --git a/backend/tests/Taskdeck.Api.Tests/ThinkingAudioApiTests.cs b/backend/tests/Taskdeck.Api.Tests/ThinkingAudioApiTests.cs index 6ae8ddbef8..5ed7d95e93 100644 --- a/backend/tests/Taskdeck.Api.Tests/ThinkingAudioApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ThinkingAudioApiTests.cs @@ -1,8 +1,10 @@ using System.Net; +using System.Data.Common; using System.Net.Http.Headers; using System.Net.Http.Json; using FluentAssertions; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; @@ -18,12 +20,24 @@ 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 ThinkingAudioApiTests(TestWebApplicationFactory factory) : IClassFixture { + private sealed class LibraryCommandProbe : DbCommandInterceptor + { + public List Commands { get; } = []; + public override ValueTask> ReaderExecutingAsync(DbCommand command, + CommandEventData eventData, InterceptionResult result, CancellationToken ct = default) + { + Commands.Add(command.CommandText); + return ValueTask.FromResult(result); + } + } + [Fact] public async Task HostRejectedOversizedBodyKeeps413AndRollsBackOriginalStorage() { @@ -308,12 +322,31 @@ public async Task LibraryKeepsAnonymousForeignAndRevokedRecordingsUnavailable() [Fact] public async Task LibraryPaginationIsBoundedAndEveryEmittedPageCanBeRead() { - var (client, _, board, card, question) = await Setup(); - var questions = Enumerable.Range(0, 21).Select(index => question with { Id = Guid.NewGuid(), Title = $"Question {index}" }).ToList(); + var (client, user, board, card, question) = await Setup(); + var questions = Enumerable.Range(0, 21).Select(index => question with { Id = Guid.NewGuid(), Title = $"Question {index}", Body = new string('x', 1000) }).ToList(); (await client.PutAsJsonAsync($"/api/boards/{board}/cards/{card}/thinking", new SaveThinkingDeckDto(1, questions))).EnsureSuccessStatusCode(); foreach (var layer in questions) await Receipt(await Upload(client, Url(board, card, layer.Id), bytes: Audio(12), revision: 2)); var first = (await client.GetFromJsonAsync("/api/thinking-audio/library"))!; first.Items.Should().HaveCount(20); first.NextOffset.Should().Be(20); + first.Items.Should().OnlyContain(item => item.QuestionExcerpt.Length == 501 && item.QuestionExcerpt.EndsWith("…")); + using (var scope = factory.Services.CreateScope()) + { + var sharedDb = scope.ServiceProvider.GetRequiredService(); + var probe = new LibraryCommandProbe(); + using var db = new TaskdeckDbContext(new DbContextOptionsBuilder() + .UseSqlite(sharedDb.Database.GetDbConnection()).AddInterceptors(probe).Options); + var repository = new ThinkingAudioRepository(db); + var ids = first.Items.Select(item => item.Id).ToArray(); + (await repository.LibraryEntriesAsync(user, ids, default)).Should().BeEquivalentTo(first.Items, options => options.WithStrictOrdering()); + probe.Commands.Should().ContainSingle(); + probe.Commands[0].Should().Contain("substr(").And.NotContain("StoredBlobChunks"); + (await repository.LibraryEntriesAsync(Guid.NewGuid(), ids, default)).Should().BeEmpty(); + (await repository.LibraryEntriesAsync(user, [], default)).Should().BeEmpty(); + probe.Commands.Should().HaveCount(2, "one bounded query per non-empty page and none for an empty page"); + db.ChangeTracker.Entries().Should().BeEmpty(); + db.ChangeTracker.Entries().Should().BeEmpty(); + db.ChangeTracker.Entries().Should().BeEmpty(); + } var second = (await client.GetFromJsonAsync($"/api/thinking-audio/library?offset={first.NextOffset}"))!; second.Items.Should().HaveCount(1); second.NextOffset.Should().BeNull(); first.Items.Select(x => x.Id).Intersect(second.Items.Select(x => x.Id)).Should().BeEmpty(); diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index 41567ecbb0..2abce0c7e3 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -2,6 +2,8 @@ Last Updated: 2026-09-10 +Source reliability/scalability continuation (#2808): preserve the payload-too-large HTTP contract through streamed audio storage rollback and replace original-library per-entry capture reads with one bounded owner-scoped metadata projection. The existing API/card/question/confirmation contracts are retained. Indexed long-history cursors remain the next distinct source scalability seam. + Private audio continuation (#2808): retain an original recording first, add manual written representations separately, then explicitly confirm into private memory. The vertical uses native source assets, bounded SQLite chunks, owner-scoped playback, idempotent upload retry, version conflicts and account portability/erasure. All four experiences share the question UI; automated transcription, real-device qualification and the wider source/attention work remain separate. This is a prototype delivery path with executable evidence, not a provider or release acceptance decision. Original-source Companion continuation (#2808): explicit per-asset source selection builds on native private-memory preservation. Bounded owner-scoped source queries, expected revisions and content hashes connect historical originals to chat receipts without implicit retrieval. All four experiences share the same picker and source contract. Execution evidence belongs to the continuation PR; hosted qualification and the wider overhaul remain separate. diff --git a/docs/STATUS.md b/docs/STATUS.md index 6d0810c8ae..2918f35b77 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,8 @@ # Taskdeck Status (Source of Truth) -Last Updated: 2026-09-09 +Last Updated: 2026-09-10 + +Source upload and library query continuation (#2808): host-rejected oversized audio bodies retain the standard 413 error after transaction rollback, with no orphaned original, reference, answer or processing request. Library pages authorize their board candidates first, then retrieve up to twenty owner-scoped metadata rows in one detached SQL projection. Evidence excerpts are clipped in SQL; listing does not load capture graphs, representation histories or binary chunks. This reduces original-library maintenance cost without changing private ownership or review/apply semantics. Long-history cursor pagination remains separate follow-through. Original-source Companion continuation (#2808): diff --git a/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md b/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md index 7f58dc2fb5..f20842808d 100644 --- a/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md +++ b/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md @@ -1,5 +1,11 @@ # Workspace overhaul validation and follow-through +## Source upload errors and library metadata (2026-09-10) + +`ThinkingAudioApiTests` injects the host's 413 exception during a partially read audio stream, exercises the real application transaction through the controller, and verifies the standard error envelope plus absence of stored blobs, references, captures, audio answers and model requests. Existing owner/access, correction, export/erasure and library cases remain in the same suite. + +The twenty-record library fixture checks exact ordered metadata, 500-character SQL excerpt clipping with an ellipsis, foreign-owner exclusion and an empty change tracker. A SQLite command interceptor proves one joined metadata query per non-empty page and no query for an empty page; binary chunks and capture histories are not materialized. The browser library path and full backend qualification are recorded with the continuation PR. This does not claim Kestrel socket transport, live deployment or transcription-provider acceptance. + ## Audio review repairs (2026-09-10) Audio drafts now bind to the board, card, question and revision present when file selection or microphone acquisition starts. Editing that question retains the local file for replay/download but prevents uploading it under new question evidence. A new draft receives the current binding. Two regressions cover edits before upload and while recording.