Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions backend/src/Taskdeck.Api/Controllers/ThinkingAudioController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -55,8 +57,16 @@ public async Task<IActionResult> Get(Guid boardId, Guid cardId, Guid layerId, Ca
public async Task<IActionResult> 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<IActionResult> Write(Guid id, ThinkingAudioWriteDto dto, CancellationToken ct)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using Taskdeck.Domain.Entities;
using Taskdeck.Application.DTOs;

namespace Taskdeck.Application.Interfaces;

public interface IThinkingAudioRepository
{
Task<IReadOnlyList<ThinkingAudioAnswer>> ListByUserAsync(Guid userId, int offset, int limit, CancellationToken ct);
Task<IReadOnlyList<ThinkingAudioLibraryEntry>> LibraryEntriesAsync(Guid userId, IReadOnlyCollection<Guid> ids, CancellationToken ct);
Task<ThinkingAudioAnswer?> QuestionAsync(Guid userId, Guid cardId, Guid layerId, string hash, CancellationToken ct);
Task<ThinkingAudioAnswer?> GetAsync(Guid userId, Guid id, CancellationToken ct);
Task<ThinkingAudioAnswer?> UploadAsync(Guid userId, Guid uploadId, CancellationToken ct);
Expand Down
14 changes: 3 additions & 11 deletions backend/src/Taskdeck.Application/Services/ThinkingAudioService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,9 @@ public async Task<Result<ThinkingAudioLibraryPage>> 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<ThinkingAudioLibraryPage>(allowed.ErrorCode, allowed.ErrorMessage);
var items = new List<ThinkingAudioLibraryEntry>();
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));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Taskdeck.Application.Interfaces;
using Taskdeck.Application.DTOs;
using Taskdeck.Domain.Entities;
using Taskdeck.Infrastructure.Persistence;

namespace Taskdeck.Infrastructure.Repositories;

public sealed class ThinkingAudioRepository(TaskdeckDbContext db) : IThinkingAudioRepository
{
public async Task<IReadOnlyList<ThinkingAudioLibraryEntry>> LibraryEntriesAsync(Guid userId, IReadOnlyCollection<Guid> 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<SourceAsset>().AsNoTracking() on answer.SourceAssetId equals asset.Id
from evidence in db.Set<SourceAsset>().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<IReadOnlyList<ThinkingAudioAnswer>> 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);
Expand Down
88 changes: 86 additions & 2 deletions backend/tests/Taskdeck.Api.Tests/ThinkingAudioApiTests.cs
Original file line number Diff line number Diff line change
@@ -1,21 +1,86 @@
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;
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;
using Taskdeck.Infrastructure.Repositories;
using Xunit;

namespace Taskdeck.Api.Tests;

public sealed class ThinkingAudioApiTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private sealed class LibraryCommandProbe : DbCommandInterceptor
{
public List<string> Commands { get; } = [];
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(DbCommand command,
CommandEventData eventData, InterceptionResult<DbDataReader> result, CancellationToken ct = default)
{
Commands.Add(command.CommandText);
return ValueTask.FromResult(result);
}
}

[Fact]
public async Task HostRejectedOversizedBodyKeeps413AndRollsBackOriginalStorage()
{
var (client, user, board, card, question) = await Setup();
using (var scope = factory.Services.CreateScope())
{
var actor = new Mock<IUserContext>();
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<ThinkingAudioService>(), 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<ObjectResult>().Subject;
response.StatusCode.Should().Be(StatusCodes.Status413PayloadTooLarge);
response.Value.Should().BeOfType<ApiErrorResponse>().Which.ErrorCode.Should().Be(ErrorCodes.PayloadTooLarge);
}
using var verify = factory.Services.CreateScope();
var db = verify.ServiceProvider.GetRequiredService<TaskdeckDbContext>();
(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<int> ReadAsync(Memory<byte> 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<int> 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");
Expand Down Expand Up @@ -257,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<ThinkingAudioLibraryPage>("/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<TaskdeckDbContext>();
var probe = new LibraryCommandProbe();
using var db = new TaskdeckDbContext(new DbContextOptionsBuilder<TaskdeckDbContext>()
.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<Taskdeck.Domain.Entities.Capture>().Should().BeEmpty();
db.ChangeTracker.Entries<SourceAsset>().Should().BeEmpty();
db.ChangeTracker.Entries<ThinkingAudioAnswer>().Should().BeEmpty();
}
var second = (await client.GetFromJsonAsync<ThinkingAudioLibraryPage>($"/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();
Expand Down
2 changes: 2 additions & 0 deletions docs/IMPLEMENTATION_MASTERPLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/STATUS.md
Original file line number Diff line number Diff line change
@@ -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):

Expand Down
6 changes: 6 additions & 0 deletions docs/product/WORKSPACE_OVERHAUL_VALIDATION.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading