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
21 changes: 21 additions & 0 deletions docs/quality/audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,27 @@ deployment-stage content: a deployment-targeted tool auditor can execute
them against the endpoint, but the stage never blocks on the E2E chain
itself.

A human reviewer (`Kind: "human"`, name `human:deployment-review`) closes
the loop as the operator acting through the standard auditor seam. Enabled
explicitly via `CodeyBox:HumanReview:Enabled` (default off, hot-reloadable;
`SweepInterval` tunes the expiry sweeper); a project drops it via
`ExcludedAuditors` by name. When composed, every code-clean iteration with
automated deployment probes also clean parks instead of passing: the item
moves to `NeedsOperatorInput` (releasing the worker slot and audit sandbox
while keeping only the deployment alive, bounded by the recipe's
`MaxLifetime`), the operator is notified with the deployment endpoint,
expiry, and acceptance criteria (backing question
`human-deployment-review-{iteration}` plus a `work_item.question_asked` /
`work_item.needs_operator_input` webhook pair), and the verdict resumes the
iteration. Approve passes and tears the deployment down immediately;
reject-with-notes yields blocking `Error` findings feeding the normal
rework loop (a fresh deployment per iteration); an undecided review past
its deadline fails closed as `expired unreviewed` — silence never passes.
Verdicts arrive via `POST /workitems/{id}/deployment-review/approve`,
`.../reject` (notes required), or by answering the backing question with
`approve` (any other text rejects with that text as notes). A late verdict
is refused with 410 and fails closed through the same expiry path.

### .NET gates need a writable NuGet home

`dotnet` materialises its per-user NuGet settings directory
Expand Down
24 changes: 24 additions & 0 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,30 @@ When the question was already dismissed (idempotent no-op):
* Returns `400 Bad Request` when `questionId` or `reason` is empty.
* Returns `404 Not Found` when the work item or question does not exist.

### `GET /workitems/{id}/deployment-review`

Returns the pending human deployment review for a parked iteration
(endpoint, expiry, backing question, brief with acceptance criteria), or
`404` when no review is outstanding.

### `POST /workitems/{id}/deployment-review/approve`

Approves the pending human deployment review; the iteration passes and the
held deployment is torn down immediately. Optional `note` (≤ 4000 chars)
is recorded on the verdict.

### `POST /workitems/{id}/deployment-review/reject`

Rejects the pending review with required `notes` (≤ 4000 chars) describing
what fails; the notes become blocking findings feeding the normal rework
loop. Returns `400` when notes are missing, `404` when no review is
pending, `409` when the item is not awaiting operator input or the review
is already decided, and `410` when the review expired unreviewed (the
expiry fails closed through teardown + dismiss + re-queue). Answering the
backing question `human-deployment-review-{iteration}` via `POST /answer`
verdicts identically: exactly `approve` approves, any other text rejects
with that text as notes.

### `DELETE /workitems/{id}`

Cancel a work item or close out a terminal-failure item.
Expand Down
31 changes: 31 additions & 0 deletions src/CodeyBox.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2751,6 +2751,19 @@ static Func<TestRunOptions> DotnetTestRunOptionsAccessor(IServiceProvider sp)
return () => monitor.CurrentValue;
});

// Human deployment reviewer (operator as reviewer through the auditor seam).
// Hot-reloadable via CodeyBox:HumanReview, explicit opt-in (default off); the
// accessor mirrors the Func<TestRunOptions> pattern so the
// ProjectAuditorComposer observes the same live IOptionsMonitor snapshot and
// composes the reviewer into the deployment stage when enabled.
builder.Services.Configure<HumanDeploymentReviewOptions>(
builder.Configuration.GetSection("CodeyBox:HumanReview"));
builder.Services.AddSingleton<Func<HumanDeploymentReviewOptions>>(sp =>
{
var monitor = sp.GetRequiredService<IOptionsMonitor<HumanDeploymentReviewOptions>>();
return () => monitor.CurrentValue;
});

builder.Services.AddSingleton(new RequiredAuditorPolicy(
builder.Configuration.GetSection("CodeyBox:RequiredAuditors").Get<string[]>() ?? []));
builder.Services.AddSingleton<ProjectAuditorComposer>();
Expand Down Expand Up @@ -3043,6 +3056,23 @@ static Func<TestRunOptions> DotnetTestRunOptionsAccessor(IServiceProvider sp)
opts.StateDatabasePath,
sp.GetRequiredService<SqliteDatabaseWriteGateFactory>());
});
builder.Services.AddSingleton<IHumanDeploymentReviewStore>(sp =>
{
var opts = sp.GetRequiredService<IOptions<CodeyBoxOptions>>().Value;
return new SqliteHumanDeploymentReviewStore(
opts.StateDatabasePath,
sp.GetRequiredService<SqliteDatabaseWriteGateFactory>());
});
builder.Services.AddHostedService(sp => new HumanDeploymentReviewSweeper(
sp.GetService<IHumanDeploymentReviewStore>(),
sp.GetService<IDeploymentManager>(),
sp.GetService<IWorkItemStore>(),
sp.GetService<IWorkItemQuestionStore>(),
sp.GetService<ITaskQueue>(),
sp.GetService<IWebhookDispatcher>(),
sp.GetService<Func<HumanDeploymentReviewOptions>>(),
TimeProvider.System,
sp.GetService<ILogger<HumanDeploymentReviewSweeper>>()));
builder.Services.AddSingleton<ITestCaseStore>(sp =>
{
var opts = sp.GetRequiredService<IOptions<CodeyBoxOptions>>().Value;
Expand Down Expand Up @@ -3632,6 +3662,7 @@ static Func<TestRunOptions> DotnetTestRunOptionsAccessor(IServiceProvider sp)
jobTrackExporter: sp.GetService<IJobTrackTestCaseExporter>(),
deploymentManager: sp.GetService<IDeploymentManager>(),
deploymentSubstrates: sp.GetService<IDeploymentSubstrateProvider>(),
humanReviews: sp.GetService<IHumanDeploymentReviewStore>(),
staleBaseReworkRouter: sp.GetRequiredService<StaleBaseConflictReworkRouter>(),
flakeEscalation: sp.GetService<NonDeterministicTestEscalationService>(),
flakeEscalationOptions: sp.GetRequiredService<NonDeterministicTestEscalationSnapshot>(),
Expand Down
210 changes: 210 additions & 0 deletions src/CodeyBox.Api/WorkItemEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ public static void Map(WebApplication app)
group.MapGet("/{id}/delegations", GetDelegationsAsync);
group.MapPost("/{id}/answer", AnswerQuestionAsync);
group.MapPost("/{id}/dismiss-question", DismissQuestionAsync);
group.MapGet("/{id}/deployment-review", GetDeploymentReviewAsync);
group.MapPost("/{id}/deployment-review/approve", ApproveDeploymentReviewAsync);
group.MapPost("/{id}/deployment-review/reject", RejectDeploymentReviewAsync);
group.MapGet("/{id}/stdout-tail", GetStdoutTailAsync);
group.MapPost("/{id}/uncancel", UncancelAsync);
group.MapPost("/{id}/resume", ResumeAsync);
Expand Down Expand Up @@ -2061,6 +2064,7 @@ private static async Task<IResult> AnswerQuestionAsync(
ITaskQueue queue,
IWebhookDispatcher webhooks,
IProjectRepository projects,
IHumanDeploymentReviewStore? reviews,
CancellationToken ct)
{
if (questionStore is null) return Results.Json(new { error = "question store not configured" }, statusCode: 503);
Expand Down Expand Up @@ -2098,12 +2102,53 @@ await webhooks.PublishAsync(new WebhookEvent
Details = new QuestionAnsweredDetails(item.Id.ToString(), item.ProjectId.Value, req.QuestionId, redactedAnswer, AnsweredBy: null),
}, ct);

// A human-review backing question carries the operator's verdict:
// exactly "approve" approves, any other answer rejects with the text
// as notes. Recorded here so the generic answer path (API or CLI)
// verdicts like the dedicated endpoints below.
await TryRecordHumanReviewVerdictAsync(item.Id, req.QuestionId, redactedAnswer, reviews, ct);

// Transition out of NeedsOperatorInput if all questions are now resolved.
await MaybeResumeFromNeedsOperatorInputAsync(item, store, questionStore, queue, webhooks, project, ct);

return Results.Ok(new { status = "answered" });
}

/// <summary>
/// Interprets an answer to a human-review backing question as a verdict.
/// Best-effort: the answer itself is already persisted, so a missing
/// review store, an already-decided review, or an expired review simply
/// leaves the verdict unrecorded (the resume path still fails closed on
/// expiry).
/// </summary>
private static async Task TryRecordHumanReviewVerdictAsync(
WorkItemId itemId,
string questionId,
string answer,
IHumanDeploymentReviewStore? reviews,
CancellationToken ct)
{
if (reviews is null || !HumanDeploymentReviewPolicy.IsReviewQuestion(questionId))
return;
var review = await reviews.GetActiveForWorkItemAsync(itemId.ToString(), ct);
if (review is null
|| review.Status != HumanDeploymentReviewStatus.Pending
|| !string.Equals(review.QuestionId, questionId, StringComparison.Ordinal))
return;
var now = DateTimeOffset.UtcNow;
if (now >= review.Deadline)
return;
var approved = HumanDeploymentReviewPolicy.IsApprovalAnswer(answer);
await reviews.RecordVerdictAsync(
review.WorkItemId,
review.Iteration,
approved,
approved ? null : HumanDeploymentReviewPolicy.TruncateNotes(answer),
decidedBy: null,
now,
ct);
}

private static async Task<IResult> DismissQuestionAsync(
string id,
DismissQuestionRequest req,
Expand Down Expand Up @@ -2154,6 +2199,157 @@ await webhooks.PublishAsync(new WebhookEvent
return Results.Ok(new { status = "dismissed" });
}

// ── Human deployment-review verdict endpoints ────────────────────────────
//
// The operator acts as a reviewer through the standard auditor seam:
// approve records a pass, reject-with-notes records blocking findings
// that feed the normal rework loop. A verdict past the review deadline
// is refused with 410 and fails closed through the shared expiry path.
// The generic POST /answer endpoint verdicts identically (answering the
// backing question with "approve" approves; any other text rejects).

private static async Task<IResult> GetDeploymentReviewAsync(
string id,
IWorkItemStore store,
IHumanDeploymentReviewStore? reviews,
CancellationToken ct)
{
if (reviews is null) return Results.Json(new { error = "human review store not configured" }, statusCode: 503);
var (item, err) = await ResolveWorkItemAsync(id, store, ct);
if (err is not null) return err;

var review = await reviews.GetActiveForWorkItemAsync(item!.Id.ToString(), ct);
if (review is null)
return Results.NotFound(new { error = "no pending human deployment review for this work item" });

return Results.Ok(new DeploymentReviewDto(
review.WorkItemId,
review.Iteration,
review.DeploymentId,
review.Deadline,
review.RequestedAt,
review.QuestionId,
review.Status.ToString(),
review.Brief));
}

private static async Task<IResult> ApproveDeploymentReviewAsync(
string id,
ApproveDeploymentReviewRequest? req,
IWorkItemStore store,
IHumanDeploymentReviewStore? reviews,
IWorkItemQuestionStore? questionStore,
IDeploymentManager? deployments,
ITaskQueue queue,
IWebhookDispatcher webhooks,
IProjectRepository projects,
CancellationToken ct)
{
if (req?.Note is { Length: > 4000 })
return Results.BadRequest(new { error = "note must be <= 4000 chars" });
return await RecordDeploymentReviewVerdictAsync(
id, approved: true, notes: req?.Note, store, reviews, questionStore,
deployments, queue, webhooks, projects, ct);
}

private static async Task<IResult> RejectDeploymentReviewAsync(
string id,
RejectDeploymentReviewRequest? req,
IWorkItemStore store,
IHumanDeploymentReviewStore? reviews,
IWorkItemQuestionStore? questionStore,
IDeploymentManager? deployments,
ITaskQueue queue,
IWebhookDispatcher webhooks,
IProjectRepository projects,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(req?.Notes))
return Results.BadRequest(new { error = "notes describing what fails are required" });
if (req!.Notes.Length > 4000)
return Results.BadRequest(new { error = "notes must be <= 4000 chars" });
return await RecordDeploymentReviewVerdictAsync(
id, approved: false, notes: req.Notes, store, reviews, questionStore,
deployments, queue, webhooks, projects, ct);
}

private static async Task<IResult> RecordDeploymentReviewVerdictAsync(
string id,
bool approved,
string? notes,
IWorkItemStore store,
IHumanDeploymentReviewStore? reviews,
IWorkItemQuestionStore? questionStore,
IDeploymentManager? deployments,
ITaskQueue queue,
IWebhookDispatcher webhooks,
IProjectRepository projects,
CancellationToken ct)
{
if (reviews is null) return Results.Json(new { error = "human review store not configured" }, statusCode: 503);
if (questionStore is null) return Results.Json(new { error = "question store not configured" }, statusCode: 503);

var (item, err) = await ResolveWorkItemAsync(id, store, ct);
if (err is not null) return err;

if (item!.State != WorkItemState.NeedsOperatorInput)
return Results.Conflict(new { error = "work item is not waiting for operator input" });

var review = await reviews.GetActiveForWorkItemAsync(item.Id.ToString(), ct);
if (review is null)
return Results.NotFound(new { error = "no pending human deployment review for this work item" });
if (review.Status != HumanDeploymentReviewStatus.Pending)
return Results.Conflict(new { error = $"review is already {review.Status.ToString().ToLowerInvariant()}; resume is pending" });

var now = DateTimeOffset.UtcNow;
if (now >= review.Deadline)
{
// Late verdict: refuse and fail closed through the shared expiry
// path (teardown + dismiss + re-queue) so silence past the
// deadline never becomes an implicit pass.
await HumanReviewExpiry.ExpireAsync(
reviews, deployments, store, questionStore, queue, webhooks,
review, now, ct: ct);
return Results.Json(new { error = "review expired unreviewed" }, statusCode: 410);
}

var recorded = await reviews.RecordVerdictAsync(
review.WorkItemId,
review.Iteration,
approved,
approved ? notes : HumanDeploymentReviewPolicy.TruncateNotes(notes ?? string.Empty),
decidedBy: null,
now,
ct);
if (!recorded)
return Results.Conflict(new { error = "review was decided concurrently" });

// Answer the backing question so the Q&A trail shows the verdict;
// the idempotent no-op branch below covers a concurrent answer.
var question = await questionStore.GetAsync(item.Id.ToString(), review.QuestionId, ct);
if (question is { State: "open" })
{
var answerText = approved ? "approve" : HumanDeploymentReviewPolicy.TruncateNotes(notes ?? string.Empty);
await questionStore.AnswerAsync(item.Id.ToString(), review.QuestionId, answerText, answeredBy: null, ct);
var project = await projects.GetAsync(item.ProjectId, ct);
await webhooks.PublishAsync(new WebhookEvent
{
Event = "work_item.question_answered",
WorkItem = item,
Project = project,
Details = new QuestionAnsweredDetails(
item.Id.ToString(), item.ProjectId.Value, review.QuestionId, answerText, AnsweredBy: null),
}, ct);
await MaybeResumeFromNeedsOperatorInputAsync(item, store, questionStore, queue, webhooks, project, ct);
}

return Results.Ok(new
{
status = approved ? "approved" : "rejected",
iteration = review.Iteration,
});
}

/// <summary>
/// When a work item is in NeedsOperatorInput state and all its questions are now
/// resolved (answered or dismissed), transitions back to WorkComplete and re-enqueues.
Expand Down Expand Up @@ -2841,6 +3037,20 @@ public sealed record AnswerQuestionRequest(string QuestionId, string Answer);

public sealed record DismissQuestionRequest(string QuestionId, string Reason);

public sealed record ApproveDeploymentReviewRequest(string? Note);

public sealed record RejectDeploymentReviewRequest(string? Notes);

public sealed record DeploymentReviewDto(
string WorkItemId,
int Iteration,
string DeploymentId,
DateTimeOffset ExpiresAt,
DateTimeOffset RequestedAt,
string QuestionId,
string Status,
string Brief);

public sealed record QuestionDto(
string Id,
string WorkItemId,
Expand Down
3 changes: 2 additions & 1 deletion src/CodeyBox.Audit/AuditPhaseLadder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ public static AuditCostClass CostClassOf(IAuditor auditor)
if (auditor.Role == AuditorRole.BuildTestGate)
return AuditCostClass.MechanicalGate;
if (auditor.Required.HasFlag(AuditCapabilities.AgentCredentials)
|| string.Equals(auditor.Kind, "llm", StringComparison.OrdinalIgnoreCase))
|| string.Equals(auditor.Kind, "llm", StringComparison.OrdinalIgnoreCase)
|| string.Equals(auditor.Kind, WellKnownAuditorKinds.Human, StringComparison.OrdinalIgnoreCase))
return AuditCostClass.Reviewer;
return AuditCostClass.Tool;
}
Expand Down
13 changes: 13 additions & 0 deletions src/CodeyBox.Audit/AuditorOrdering.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,25 @@ namespace CodeyBox.Audit;
/// <summary>
/// Single source of truth for audit-panel ordering. This lives in the audit
/// layer so Core stays limited to neutral auditor contract metadata.
/// Tiers derive from declared capabilities only — never from concrete types.
/// </summary>
public static class AuditorOrdering
{
/// <summary>
/// True when the auditor is a human reviewer (declared
/// <c>Kind = "human"</c>). Human reviewers park the pipeline awaiting an
/// operator verdict, so they sort after every automated auditor.
/// </summary>
public static bool IsHuman(IAuditor auditor)
{
ArgumentNullException.ThrowIfNull(auditor);
return string.Equals(auditor.Kind, WellKnownAuditorKinds.Human, StringComparison.OrdinalIgnoreCase);
}

public static int TierOf(IAuditor auditor)
=> auditor.Role == AuditorRole.BuildTestGate ? 0
: auditor.CanShortCircuitOnBlockingFinding ? 1
: IsHuman(auditor) ? 4
: auditor.Required.HasFlag(AuditCapabilities.AgentCredentials) ? 3
: 2;
}
Loading
Loading