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
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,6 @@ public async Task<RemotePullRequestReview> Handle(SubmitPullRequestReviewCommand
// no identity to act as — reject rather than silently fall back.
var actorUserId = _currentUser.Id ?? throw new UnauthorizedAccessException("A pull request review can only be submitted by an authenticated user.");

return await _service.SubmitReviewAsync(request.RepositoryId, _currentTeam.Id!.Value, request.Number, request.Verdict, request.Body, actorUserId, cancellationToken).ConfigureAwait(false);
return await _service.SubmitReviewAsync(request.RepositoryId, _currentTeam.Id!.Value, request.Number, new SubmitPullRequestReviewInput { Verdict = request.Verdict, Body = request.Body }, actorUserId, cancellationToken).ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- 0242_tool_call_ledger_approval_preview.sql
--
-- An agent tool call parked for a human's approval used to post a card naming only the run, the tool and the tool's
-- generic description: the reviewer could not see which repository, pull request, head commit or command they were
-- approving, and the ledger kept only a hash of the arguments. The handler now resolves the arguments server-side into a
-- redacted, bounded preview before parking, renders it on the card, and stamps it on the row in the same park CAS that
-- stamps the approval token. A merge's preview pins the head commit it showed; the approved call executes with that pin.
--
-- approval_target is the server-derived key of the call's target (a merge: its repository and pull request at the head and
-- base its card pinned). A target a reviewer rejected is not asked again in the same run, whatever inert or cosmetic
-- argument the agent changes; while one call on a target awaits a reviewer no other is parked on it; a rejection fails
-- every undecided call of the run on the target, and an approved one that has not run is not run.
--
-- Additive: two nullable columns, no backfill (a row parked before this has no preview and no target, and executes as it
-- always did). Idempotent (IF NOT EXISTS). An older pod ignores both columns. Every target lookup is scoped to one run and
-- rides the existing (team_id, agent_run_id, created_date, id) index.

ALTER TABLE tool_call_ledger ADD COLUMN IF NOT EXISTS approval_preview_jsonb jsonb NULL;

ALTER TABLE tool_call_ledger ADD COLUMN IF NOT EXISTS approval_target VARCHAR(200) NULL;

COMMENT ON COLUMN tool_call_ledger.approval_preview_jsonb IS
'The redacted, bounded ToolCallPreview the approval card was built from: what the reviewer saw, and the pins the approved call executes with.';

COMMENT ON COLUMN tool_call_ledger.approval_target IS
'Server-derived toolKind:sha256 key of the call target a reviewer approves or rejects; a rejected target is not asked again in the same run, and holds at most one live card.';
14 changes: 14 additions & 0 deletions backend/src/CodeSpace.Core/Persistence/Entities/ToolCallLedger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@ public class ToolCallLedger : IEntity<Guid>, IAuditable
/// <summary>When the call was approved (item D). NULL distinguishes a not-yet-decided AwaitingApproval row from an approved-but-not-yet-executed one — the D3 reaper only expires <c>approved_at IS NULL</c> rows.</summary>
public DateTimeOffset? ApprovedAt { get; set; }

/// <summary>
/// The redacted, bounded <c>ToolCallPreview</c> its approval card was built from, stamped by the park CAS: what the
/// reviewer saw, and the pins (a merge's head commit) the approved call executes with. jsonb. NULL on a row that never
/// parked for approval, and on a decision row.
/// </summary>
public string? ApprovalPreviewJson { get; set; }

/// <summary>
/// Server-derived key of what a reviewer approves or rejects — <c>toolKind:SHA-256(canonical(target))</c>, the call's
/// target as its tool reads it (a merge: its repository and pull request). Stamped by the park CAS; a target a reviewer
/// rejected is not asked again in the same run. Never read from the wire, never operator-facing.
/// </summary>
public string? ApprovalTarget { get; set; }

/// <summary>The <see cref="AgentRun.FenceEpoch"/> of the attempt responsible for the row: stamped at claim, re-stamped when an approved call begins executing. A Pending / Running row older than a live caller's epoch was left by a lost attempt.</summary>
public long FenceEpoch { get; set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public void Configure(EntityTypeBuilder<ToolCallLedger> builder)
builder.Property(l => l.ApprovalDeadlineAt).HasColumnName("approval_deadline_at");
builder.Property(l => l.ApprovedByUserId).HasColumnName("approved_by_user_id");
builder.Property(l => l.ApprovedAt).HasColumnName("approved_at");
builder.Property(l => l.ApprovalPreviewJson).HasColumnName("approval_preview_jsonb").HasColumnType("jsonb");
builder.Property(l => l.ApprovalTarget).HasColumnName("approval_target").HasMaxLength(200);
builder.Property(l => l.FenceEpoch).HasColumnName("fence_epoch");

var admissionOrdinal = builder.Property(l => l.AdmissionOrdinal).HasColumnName("admission_ordinal").ValueGeneratedOnAdd();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using CodeSpace.Messages.Failures;

namespace CodeSpace.Core.Services.Agents.Exceptions;

/// <summary>
/// A tool call could not be shown to a human for approval as it would run — the pull request it names could not be read,
/// or its head is not the commit the call names — so it is answered to the model instead of parked. The message is
/// model-facing and is redacted at the MCP handler's choke point like every tool result.
/// </summary>
public sealed class ToolCallPreviewException(string message) : Exception(message), IFailure
{
public FailureKind Kind => FailureKind.Unprocessable;
public string Code => FailureCodes.ToolCallNotPreviewable;
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ private sealed class CheckedTool : IAgentTool
public bool AlwaysRequiresApproval => _inner.AlwaysRequiresApproval;
public AgentToolValidation ValidateInput(JsonElement input) => _inner.ValidateInput(input);
public Task<string?> RefusalAsync(AgentToolCall call, CancellationToken cancellationToken) => _inner.RefusalAsync(call with { RunId = _context.AgentRunId, TeamId = _context.TeamId }, cancellationToken);
public Task<ToolCallPreview> PreviewAsync(AgentToolCall call, CancellationToken cancellationToken) => _inner.PreviewAsync(call with { RunId = _context.AgentRunId, TeamId = _context.TeamId }, cancellationToken);
public async Task<AgentToolResult> CallAsync(AgentToolCall call, CancellationToken cancellationToken)
{
if (await _context.Guard.CheckAsync(_context.AgentRunId, _context.TeamId, Kind, cancellationToken).ConfigureAwait(false) is { } failure) return AgentToolResult.Fail($"{failure.Code}: {failure.Message}");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ namespace CodeSpace.Core.Services.Agents.Mcp;
/// Records the human's DECISION (approve / reject) on a parked tool-call approval (durable mid-turn HITL, item D) and
/// wakes any in-memory waiter so a blocked handler call (item D2) resumes. It does NOT run the side effect — approve
/// only STAMPS the decision (the row stays <c>AwaitingApproval</c>; the handler flips it to terminal once it executes);
/// reject drives an undecided <c>AwaitingApproval → Failed</c> directly. Both CASes require a not-yet-approved row, so
/// the row takes the first decision even from two cards carrying one token. Owns the status-guarded CAS over the
/// reject drives an undecided <c>AwaitingApproval → Failed</c> directly, and with it every other undecided call of the run
/// on the same target. Both CASes require a not-yet-approved row, so the row takes the first decision even from two cards
/// carrying one token. Owns the status-guarded CAS over the
/// ledger row (mirrors <see cref="ToolCallLedgerService"/>.RecordTerminalAsync) and team-scopes every read for defense-in-depth (mirrors
/// <c>WorkflowResumeService.ResumeByActionTokenAsync</c>). Returns an <see cref="ActionResumeResult"/> so the chat
/// caller knows whether to stamp the card (Resumed / NoWait) or reject a late click (AlreadyResolved).
Expand Down Expand Up @@ -53,7 +54,7 @@ public async Task<ActionResumeResult> ResolveByTokenAsync(string token, string r
// index on approval_token (migration 0049) keeps this lookup tiny.
var row = await _db.ToolCallLedger.AsNoTracking()
.Where(l => l.ApprovalToken == token && l.TeamId == teamId)
.Select(l => new { l.Id, l.Status })
.Select(l => new ParkedRow(l.Id, l.Status, l.AgentRunId, l.ApprovalTarget))
.FirstOrDefaultAsync(ct).ConfigureAwait(false);

if (row == null)
Expand All @@ -72,7 +73,7 @@ public async Task<ActionResumeResult> ResolveByTokenAsync(string token, string r
// in the living thread WITHOUT resolving the approval — it must never approve or fail the row.
return responseKey switch
{
Reject => await RejectAsync(row.Id, teamId, actorUserId, ct).ConfigureAwait(false),
Reject => await RejectAsync(row, teamId, actorUserId, ct).ConfigureAwait(false),
Approve => await ApproveAsync(row.Id, teamId, actorUserId, ct).ConfigureAwait(false),
_ => NoWaitForUnknownKey(row.Id, responseKey),
};
Expand All @@ -82,25 +83,54 @@ public async Task<ActionResumeResult> ResolveByTokenAsync(string token, string r
// recorded in last_modified_by, not in the error: the error is what the model is replayed. The approved_at == null
// guard mirrors ApproveAsync's: a card only serializes its own clicks, so a reject on a second card after an approve
// must lose here rather than fail an approved call before it runs.
private async Task<ActionResumeResult> RejectAsync(Guid ledgerId, Guid teamId, Guid actorUserId, CancellationToken ct)
private async Task<ActionResumeResult> RejectAsync(ParkedRow row, Guid teamId, Guid actorUserId, CancellationToken ct)
{
if (await FailUndecidedAsync([row.Id], teamId, actorUserId, ct).ConfigureAwait(false) == 0) return ActionResumeResult.AlreadyResolved;

_waiters.TrySignal(row.Id, ToolApprovalOutcome.Rejected);

_logger.LogInformation("Tool-call approval rejected. LedgerId={LedgerId} By={ActorUserId}", row.Id, actorUserId);

await RejectSiblingsAsync(row, teamId, actorUserId, ct).ConfigureAwait(false);

return ActionResumeResult.Resumed;
}

// The rejection reaches every other undecided call of the run on the same target — one parked an instant apart on
// another connection, or before a target held one card: rejecting one card must not leave an approvable twin. An
// approved sibling is left to the handler, which re-checks the target before it runs one; a later fresh call on the
// target is denied at park.
private async Task RejectSiblingsAsync(ParkedRow row, Guid teamId, Guid actorUserId, CancellationToken ct)
{
if (row.ApprovalTarget is null) return;

var siblings = await _db.ToolCallLedger.AsNoTracking()
.Where(l => l.AgentRunId == row.AgentRunId && l.TeamId == teamId && l.ApprovalTarget == row.ApprovalTarget && l.Id != row.Id && l.Status == ToolCallLedgerStatus.AwaitingApproval && l.ApprovedAt == null)
.Select(l => l.Id)
.ToListAsync(ct).ConfigureAwait(false);

if (siblings.Count == 0) return;

await FailUndecidedAsync(siblings, teamId, actorUserId, ct).ConfigureAwait(false);

foreach (var sibling in siblings) _waiters.TrySignal(sibling, ToolApprovalOutcome.Rejected);

_logger.LogInformation("Tool-call approval rejection reached {Count} other call(s) of run {RunId} on the same target. LedgerId={LedgerId}", siblings.Count, row.AgentRunId, row.Id);
}

/// <summary>The status-guarded reject CAS over <paramref name="ledgerIds"/>: each still-undecided one fails with <see cref="RejectedError"/>. Returns how many it failed.</summary>
private async Task<int> FailUndecidedAsync(IReadOnlyList<Guid> ledgerIds, Guid teamId, Guid actorUserId, CancellationToken ct)
{
var now = DateTimeOffset.UtcNow;

var affected = await _db.ToolCallLedger
.Where(l => l.Id == ledgerId && l.TeamId == teamId && l.Status == ToolCallLedgerStatus.AwaitingApproval && l.ApprovedAt == null)
return await _db.ToolCallLedger
.Where(l => ledgerIds.Contains(l.Id) && l.TeamId == teamId && l.Status == ToolCallLedgerStatus.AwaitingApproval && l.ApprovedAt == null)
.ExecuteUpdateAsync(s => s
.SetProperty(l => l.Status, ToolCallLedgerStatus.Failed)
.SetProperty(l => l.Error, RejectedError)
.SetProperty(l => l.LastModifiedDate, now)
.SetProperty(l => l.LastModifiedBy, actorUserId), ct)
.ConfigureAwait(false);

if (affected == 0) return ActionResumeResult.AlreadyResolved;

_waiters.TrySignal(ledgerId, ToolApprovalOutcome.Rejected);

_logger.LogInformation("Tool-call approval rejected. LedgerId={LedgerId} By={ActorUserId}", ledgerId, actorUserId);
return ActionResumeResult.Resumed;
}

// Approve — status-guarded CAS that STAMPS the decision WITHOUT changing status (the row stays AwaitingApproval; the
Expand All @@ -127,6 +157,9 @@ private async Task<ActionResumeResult> ApproveAsync(Guid ledgerId, Guid teamId,
return ActionResumeResult.Resumed;
}

/// <summary>The parked row a click names: what the reject needs to reach the row's siblings on the same target.</summary>
private sealed record ParkedRow(Guid Id, ToolCallLedgerStatus Status, Guid AgentRunId, string? ApprovalTarget);

private ActionResumeResult NoWaitForUnknownKey(Guid ledgerId, string responseKey)
{
_logger.LogDebug("Tool-call approval resolve: unknown responseKey {ResponseKey} for ledger {LedgerId} — recording without resolving (fail-safe, never approves)", responseKey, ledgerId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using CodeSpace.Core.DependencyInjection;
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Services.Agents.Exceptions;
using CodeSpace.Core.Services.Agents.Tools;
using CodeSpace.Messages.Dtos.Agents;
using CodeSpace.Messages.Queries.Agents;
using Microsoft.EntityFrameworkCore;
Expand All @@ -29,7 +30,7 @@ public sealed class ToolCallAuditReader : IToolCallAuditReader, IScopedDependenc
public ToolCallAuditReader(CodeSpaceDbContext db) { _db = db; }

public async Task<IReadOnlyList<ToolCallView>> ListForRunAsync(Guid agentRunId, Guid teamId, CancellationToken cancellationToken) =>
await AuditRowsQuery(_db, agentRunId, teamId).ToListAsync(cancellationToken).ConfigureAwait(false);
(await AuditRowsQuery(_db, agentRunId, teamId).ToListAsync(cancellationToken).ConfigureAwait(false)).Select(ToView).ToList();

public async Task<ToolCallPage?> PageForRunAsync(PageToolCallsQuery request, Guid teamId, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -57,22 +58,25 @@ public async Task<IReadOnlyList<ToolCallView>> ListForRunAsync(Guid agentRunId,
/// <summary>
/// Exact tenant/run-scoped audit projection, ordered chronologically in PostgreSQL. Only fields serialized by
/// <see cref="ToolCallView"/> are selected: notably not <c>ResultJson</c>, the decision envelope, approval bearer,
/// idempotency key or input hash. Internal so the translated SQL—not merely the DTO shape—is test-pinned.
/// approval target, idempotency key or input hash. The approval preview is selected: it is what the reviewer saw,
/// redacted and bounded at park. Internal so the translated SQL—not merely the DTO shape—is test-pinned.
/// </summary>
internal static IQueryable<ToolCallView> AuditRowsQuery(CodeSpaceDbContext db, Guid agentRunId, Guid teamId) =>
internal static IQueryable<ToolCallAuditPageRow> AuditRowsQuery(CodeSpaceDbContext db, Guid agentRunId, Guid teamId) =>
db.ToolCallLedger.AsNoTracking()
.Where(row => row.AgentRunId == agentRunId && row.TeamId == teamId)
.OrderBy(row => row.CreatedDate)
.ThenBy(row => row.Id)
.Select(row => new ToolCallView
.Select(row => new ToolCallAuditPageRow
{
Id = row.Id,
ToolKind = row.ToolKind,
Status = row.Status,
CreatedDate = row.CreatedDate,
LastModifiedDate = row.LastModifiedDate,
Error = row.Error,
ApprovedByUserId = row.ApprovedByUserId,
ApprovedAt = row.ApprovedAt,
PreviewJson = row.ApprovalPreviewJson,
});

/// <summary>The sole row-bearing page query: exact tenant/run keyset and only cursor + existing safe view columns.</summary>
Expand All @@ -93,6 +97,7 @@ internal static IQueryable<ToolCallAuditPageRow> PageRowsQuery(CodeSpaceDbContex
Error = row.Error,
ApprovedByUserId = row.ApprovedByUserId,
ApprovedAt = row.ApprovedAt,
PreviewJson = row.ApprovalPreviewJson,
});
}

Expand All @@ -105,6 +110,7 @@ internal static IQueryable<ToolCallAuditPageRow> PageRowsQuery(CodeSpaceDbContex
Error = row.Error,
ApprovedByUserId = row.ApprovedByUserId,
ApprovedAt = row.ApprovedAt,
Preview = ToolCallPreviews.Parse(row.PreviewJson),
};
}

Expand All @@ -118,6 +124,7 @@ internal sealed record ToolCallAuditPageRow
public string? Error { get; init; }
public Guid? ApprovedByUserId { get; init; }
public DateTimeOffset? ApprovedAt { get; init; }
public string? PreviewJson { get; init; }
}

internal readonly record struct ToolCallAuditCursor(DateTimeOffset CreatedDate, Guid Id)
Expand Down
Loading
Loading