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
2 changes: 1 addition & 1 deletion autodoc/interfaces/proposal-operation-vocabulary.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Card operations:
- `update`: `cardId`; at least one of `title`, `description`, `dueDate`, `clearDueDate`, `labels`, or `labelIds`. At the operation-payload layer, an explicit null `dueDate` clears it. The chat tool treats `due_date: null` as omitted and emits clearing only for `clear_due_date: true`. `dueDate` and a true `clearDueDate` are mutually exclusive.
- `move`: `cardId`, `columnId`.
- `archive`: `cardId`. This legacy identity permanently retains Block semantics: applying it marks the card blocked with the generated reason `Archived by an approved proposal.` The preview shows that exact blocked-state transition before approval. Existing approvals and the legacy MCP `archive_card` and chat `propose_archive_card` tools keep this contract.
- `archive-lifecycle` / `restore-lifecycle`: `cardId`, `expectedUpdatedAt` (the displayed card timestamp). Archive hides the retained card from active work; restore returns it to its original column/position, subject to that column's WIP limit measured against the occupancy the same proposal's earlier operations produce, so a restore behind a card create or move that takes the last slot is refused at preview instead of failing mid-apply (`#2926`). A restore cannot sit behind another lifecycle operation: `ProposalHierarchyValidator` admits at most one hierarchy-affecting operation per proposal, so batch archive/restore in a single proposal is refused outright and the projection's lifecycle deltas are carried only against that gate being relaxed later. Preview and Apply reject stale timestamps, wrong archive state, unavailable columns, and another operation on the same card in the same proposal. The separate MCP `archive_card_lifecycle` / `restore_archived_card` tools only create proposals; explicit approval and Apply remain required. Both names are in `SideEffectAnalyzer.CardMutatingActions`, so the review Cards row discloses the board mutation, and Apply persists exactly one `Archived`/`Unarchived` audit row written by `ExecutionAuditRecorder` with the proposal provenance (the handler passes `recordLifecycleAudit: false` so `CardService.SetArchivedAsync` does not stage a second row); direct API archive/restore keeps its own single actor-stamped row (`#2939`). The realtime `card.archived`/`card.restored` event (and the `card.updated` events for children detached by an archive) is likewise staged on the apply lane: the handler passes the executor's `DeferredBoardRealtimeNotifier` as `notificationSink`, and `AutomationExecutorService` publishes the buffer only after `CommitTransactionAsync` and discards it on every non-commit exit, so an operation that fails later emits nothing; direct API archive/restore passes no sink and still notifies immediately (`#2934`); the trade is that a lifecycle event's outbound webhook delivery rows are now written by that post-commit flush, outside any transaction, so a host crash between the commit and the flush loses them with no retry, where before they rolled back with the mutation - tracked in `#3024`.
- `archive-lifecycle` / `restore-lifecycle`: `cardId`, `expectedUpdatedAt` (the displayed card timestamp). Archive hides the retained card from active work; restore returns it to its original column/position, subject to that column's WIP limit measured against the occupancy the same proposal's earlier operations produce, so a restore behind a card create or move that takes the last slot is refused at preview instead of failing mid-apply (`#2926`). A restore cannot sit behind another lifecycle operation: `ProposalHierarchyValidator` admits at most one hierarchy-affecting operation per proposal, so batch archive/restore in a single proposal is refused outright and the projection's lifecycle deltas are carried only against that gate being relaxed later. Preview and Apply reject stale timestamps, wrong archive state, unavailable columns, and another operation on the same card in the same proposal. The separate MCP `archive_card_lifecycle` / `restore_archived_card` tools only create proposals; explicit approval and Apply remain required. Both names are in `SideEffectAnalyzer.CardMutatingActions`, so the review Cards row discloses the board mutation, and Apply persists exactly one `Archived`/`Unarchived` audit row written by `ExecutionAuditRecorder` with the proposal provenance (the handler passes `recordLifecycleAudit: false` so `CardService.SetArchivedAsync` does not stage a second row); direct API archive/restore keeps its own single actor-stamped row (`#2939`). The realtime `card.archived`/`card.restored` event (and the `card.updated` events for children detached by an archive) is likewise staged on the apply lane: the handler passes the executor's `DeferredBoardRealtimeNotifier` as `notificationSink`, and `AutomationExecutorService` publishes the buffer only after `CommitTransactionAsync` and discards it on every non-commit exit, so an operation that fails later emits nothing; direct API archive/restore passes no sink and still notifies immediately (`#2934`). The executor prepares filtered outbound webhook delivery rows through `ITransactionalBoardMutationNotifier` before its final Applied-status save, so those rows and subscription trigger timestamps commit or roll back with the proposal. After commit, prepared events publish realtime only; the existing delivery worker can claim the durable rows even if the realtime flush is lost (`#3024`). This covers already-buffered proposal events and does not change immediate notification producers or the separate assignment collector.
- `add-label` / `remove-label`: `cardId` plus exactly one of board-scoped `labelId` or `labelName`. Separator-free and underscore aliases remain accepted at apply time for existing callers.
- `add-relation` / `remove-relation`: `boardId`, source `cardId`, target `relatedCardId`, `relationType`, and mandatory non-negative `expectedRevision`; `targetType` is `card`. The admitted input kinds are `relates-to`, `blocks`, `depends-on`, `duplicates`, and `spawned-from`. `depends-on(A, B)` is stored and previewed as `blocks(B, A)`; `relates-to` is deterministically ordered, while the other kinds retain direction. Both endpoints must be active cards on the proposal board, except for a preceding card create with a preallocated operation target ID, which is valid within the ordered proposal. A proposal contains at most one relation operation and cannot combine it with card archive-lifecycle, restore-lifecycle, or delete. Apply preserves the caller's original relation revision pin; it never restamps it after an earlier operation.
- `workItemType` (`Task` / `Epic` / `Spike`) is read only by `create` and `update`; every other card action ignores it at apply, so `ProposalOperationContractValidator` rejects it there with `Parameter 'workItemType' is not supported by card action '<action>'` rather than letting the preview announce a type transition Apply would not perform (`#2950`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace Taskdeck.Api.Realtime;

public sealed class CompositeBoardRealtimeNotifier : IBoardRealtimeNotifier
public sealed class CompositeBoardRealtimeNotifier : IBoardRealtimeNotifier, ITransactionalBoardMutationNotifier
{
private readonly SignalRBoardRealtimeNotifier _signalRNotifier;
private readonly WebhookBoardMutationNotifier _webhookNotifier;
Expand Down Expand Up @@ -35,6 +35,20 @@ await NotifySafeAsync(
ct => _webhookNotifier.NotifyBoardMutationAsync(mutation, ct));
}

public Task StageBoardMutationAsync(
BoardRealtimeEvent mutation,
CancellationToken cancellationToken = default) =>
_webhookNotifier.StageBoardMutationAsync(mutation, cancellationToken);

public Task NotifyCommittedBoardMutationAsync(
BoardRealtimeEvent mutation,
CancellationToken cancellationToken = default) =>
NotifySafeAsync(
"signalr",
mutation,
cancellationToken,
ct => _signalRNotifier.NotifyBoardMutationAsync(mutation, ct));

private async Task NotifySafeAsync(
string channel,
BoardRealtimeEvent mutation,
Expand Down
12 changes: 12 additions & 0 deletions backend/src/Taskdeck.Api/Realtime/WebhookBoardMutationNotifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,16 @@ public async Task NotifyBoardMutationAsync(
mutation.BoardId);
}
}

public async Task StageBoardMutationAsync(
BoardRealtimeEvent mutation,
CancellationToken cancellationToken = default)
{
var result = await _outboundWebhookService.StageBoardMutationAsync(mutation, cancellationToken);
if (!result.IsSuccess)
{
throw new InvalidOperationException(
$"Could not stage outbound webhook deliveries ({result.ErrorCode}).");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,12 @@ await _auditRecorder.RecordAsync(operation, effectiveProposal, cancellationToken
return Result.Failure<ProposalExecutionReceipt>(failedResult.ErrorCode, failureReason);
}

// Prepare durable webhook deliveries while the operation transaction is still open.
// The Applied status save below persists the board writes, audit rows, status, and
// prepared Pending deliveries together. Preparation failures must abort all of them.
if (deferredNotifications is not null)
await deferredNotifications.PrepareAsync(cancellationToken);

// The board marker, operation effects, audit rows, and Applied status share this outer
// transaction. Do not re-check archived state here: an approved operation may itself
// archive the board, and the pre-operation guard already ordered that legitimate write.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public sealed class DeferredBoardRealtimeNotifier : IBoardRealtimeNotifier
{
private readonly IBoardRealtimeNotifier _inner;
private readonly List<BoardRealtimeEvent> _pending = new();
private int _preparedCount;

public DeferredBoardRealtimeNotifier(IBoardRealtimeNotifier? inner = null)
{
Expand All @@ -30,6 +31,9 @@ public DeferredBoardRealtimeNotifier(IBoardRealtimeNotifier? inner = null)
/// <summary>Events staged but not yet published. Diagnostics and tests only.</summary>
public int PendingCount => _pending.Count;

/// <summary>Events whose durable channel has been prepared in the caller's transaction.</summary>
public int PreparedCount => _preparedCount;

public Task NotifyBoardMutationAsync(
BoardRealtimeEvent mutation,
CancellationToken cancellationToken = default)
Expand All @@ -38,6 +42,23 @@ public Task NotifyBoardMutationAsync(
return Task.CompletedTask;
}

/// <summary>
/// Prepares the durable channel for every pending event without draining the buffer. Sinks
/// without the supplemental transactional capability retain the legacy flush behavior.
/// Successfully prepared events are remembered so a repeated call cannot stage duplicates.
/// </summary>
public async Task PrepareAsync(CancellationToken cancellationToken = default)
{
if (_inner is not ITransactionalBoardMutationNotifier transactional)
return;

while (_preparedCount < _pending.Count)
{
await transactional.StageBoardMutationAsync(_pending[_preparedCount], cancellationToken);
_preparedCount++;
}
}

/// <summary>
/// Publishes every staged event downstream, in staging order, and empties the buffer.
/// The buffer is emptied before the first publish, so a downstream failure can never
Expand All @@ -49,11 +70,22 @@ public async Task FlushAsync(CancellationToken cancellationToken = default)
return;

var batch = _pending.ToArray();
var preparedCount = _preparedCount;
_pending.Clear();
foreach (var mutation in batch)
await _inner.NotifyBoardMutationAsync(mutation, cancellationToken);
_preparedCount = 0;
for (var index = 0; index < batch.Length; index++)
{
if (index < preparedCount && _inner is ITransactionalBoardMutationNotifier transactional)
await transactional.NotifyCommittedBoardMutationAsync(batch[index], cancellationToken);
else
await _inner.NotifyBoardMutationAsync(batch[index], cancellationToken);
}
}

/// <summary>Drops every staged event — the write they describe did not survive.</summary>
public void Discard() => _pending.Clear();
public void Discard()
{
_pending.Clear();
_preparedCount = 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,8 @@ Task<Result> RevokeSubscriptionAsync(
Task<Result> EnqueueBoardMutationAsync(
BoardRealtimeEvent mutation,
CancellationToken cancellationToken = default);

Task<Result> StageBoardMutationAsync(
BoardRealtimeEvent mutation,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Taskdeck.Application.Services;

/// <summary>
/// Optional capability for notification sinks that can prepare durable work inside a caller-owned
/// transaction, then publish only the non-durable channel after that transaction commits.
/// </summary>
public interface ITransactionalBoardMutationNotifier
{
Task StageBoardMutationAsync(
BoardRealtimeEvent mutation,
CancellationToken cancellationToken = default);

Task NotifyCommittedBoardMutationAsync(
BoardRealtimeEvent mutation,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,20 @@ public async Task<Result> RevokeSubscriptionAsync(
public async Task<Result> EnqueueBoardMutationAsync(
BoardRealtimeEvent mutation,
CancellationToken cancellationToken = default)
{
var stageResult = await StageBoardMutationAsync(mutation, cancellationToken);
if (!stageResult.IsSuccess)
{
return stageResult;
}

await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}

public async Task<Result> StageBoardMutationAsync(
BoardRealtimeEvent mutation,
CancellationToken cancellationToken = default)
{
var eventType = $"{mutation.EntityType}.{mutation.Operation}".Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(eventType) || eventType == ".")
Expand Down Expand Up @@ -202,7 +216,6 @@ public async Task<Result> EnqueueBoardMutationAsync(
subscription.MarkTriggered();
}

await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,53 @@ public async Task NotifyBoardMutationAsync_ShouldNotThrow_WhenBothChannelsFail()
webhookLogger.Entries.Should().ContainSingle(e => e.Level == LogLevel.Error);
}

[Fact]
public async Task StageBoardMutationAsync_ShouldPrepareWebhookOnly_AndPropagateFailure()
{
var mutation = CreateMutation();
var signalRClientProxy = new RecordingClientProxy();
var outboundService = new RecordingOutboundWebhookService();
var notifier = CreateNotifier(signalRClientProxy, outboundService);

await notifier.StageBoardMutationAsync(mutation, CancellationToken.None);

outboundService.StageCalls.Should().ContainSingle();
outboundService.EnqueueCalls.Should().BeEmpty();
signalRClientProxy.MethodName.Should().BeNull();

outboundService.StageResultToReturn = Result.Failure("stage_failed", "queue failed");
var act = () => notifier.StageBoardMutationAsync(CreateMutation(), CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
}

[Fact]
public async Task NotifyCommittedBoardMutationAsync_ShouldPublishSignalROnly()
{
var mutation = CreateMutation();
var signalRClientProxy = new RecordingClientProxy();
var outboundService = new RecordingOutboundWebhookService();
var notifier = CreateNotifier(signalRClientProxy, outboundService);

await notifier.NotifyCommittedBoardMutationAsync(mutation, CancellationToken.None);

signalRClientProxy.MethodName.Should().Be("boardMutation");
outboundService.StageCalls.Should().BeEmpty();
outboundService.EnqueueCalls.Should().BeEmpty();
}

private static CompositeBoardRealtimeNotifier CreateNotifier(
RecordingClientProxy signalRClientProxy,
RecordingOutboundWebhookService outboundService)
{
var hubContext = new FakeHubContext(signalRClientProxy);
return new CompositeBoardRealtimeNotifier(
new SignalRBoardRealtimeNotifier(hubContext),
new WebhookBoardMutationNotifier(
outboundService,
new InMemoryLogger<WebhookBoardMutationNotifier>()),
new InMemoryLogger<CompositeBoardRealtimeNotifier>());
}

private static BoardRealtimeEvent CreateMutation()
{
return new BoardRealtimeEvent(
Expand All @@ -187,8 +234,11 @@ private static BoardRealtimeEvent CreateMutation()

private sealed class RecordingOutboundWebhookService : IOutboundWebhookService
{
public List<(BoardRealtimeEvent Mutation, CancellationToken CancellationToken)> Calls { get; } = [];
public List<(BoardRealtimeEvent Mutation, CancellationToken CancellationToken)> EnqueueCalls { get; } = [];
public List<(BoardRealtimeEvent Mutation, CancellationToken CancellationToken)> StageCalls { get; } = [];
public List<(BoardRealtimeEvent Mutation, CancellationToken CancellationToken)> Calls => EnqueueCalls;
public Result ResultToReturn { get; set; } = Result.Success();
public Result StageResultToReturn { get; set; } = Result.Success();
public Exception? ExceptionToThrow { get; set; }

public Task<Result<OutboundWebhookSubscriptionSecretDto>> CreateSubscriptionAsync(
Expand Down Expand Up @@ -229,7 +279,7 @@ public Task<Result> EnqueueBoardMutationAsync(
BoardRealtimeEvent mutation,
CancellationToken cancellationToken = default)
{
Calls.Add((mutation, cancellationToken));
EnqueueCalls.Add((mutation, cancellationToken));

if (ExceptionToThrow is not null)
{
Expand All @@ -238,6 +288,18 @@ public Task<Result> EnqueueBoardMutationAsync(

return Task.FromResult(ResultToReturn);
}

public Task<Result> StageBoardMutationAsync(
BoardRealtimeEvent mutation,
CancellationToken cancellationToken = default)
{
StageCalls.Add((mutation, cancellationToken));

if (ExceptionToThrow is not null)
throw ExceptionToThrow;

return Task.FromResult(StageResultToReturn);
}
}

private sealed class FakeHubContext : IHubContext<BoardsHub>
Expand Down
Loading
Loading