d83ad87151
SharedKernel:
- ActionRisk (risk lives on the action) + GatePolicy (the pure autonomy x risk matrix:
Read never holds, Draft/Publish hold unless Autonomous, Destructive ALWAYS holds).
- IActionGate (AgentActionProposal -> execute|hold) and IBoardWriter.AttachArtifactAsync.
Governance:
- ReviewItem (held action: artifact, child titles, trace, decision, edit distance) in a new
review_items table (AddReviewItems migration).
- ActionGate: hold -> ReviewItem + "action.held" audit; autonomous -> execute + audit.
- HeldActionExecutor: writes the artifact onto the task and creates the child tasks via
IBoardWriter (implemented by OrgBoard — no cross-module table access).
- Review inbox API: GET /api/governance/reviews (scope-filtered to where the caller may
approve), POST /reviews/{id}/approve (optional edited content/children -> normalized
edit distance recorded — the north-star metric), POST /reviews/{id}/sendback. Deciding
twice is 409; Members are 403.
Assembler:
- OutputParser (numbered-list child titles, conservative) and the executor now hands every
completed run's proposal to the gate.
OrgBoard: WorkItem.AttachArtifact + BoardWriter.AttachArtifactAsync.
Verified: build green; ArchitectureTests 8/8; IntegrationTests 41/41 incl. the full M5
acceptance — Aria (gated) proposes a spec, it waits in the inbox with its trace, a Member is
403'd, the owner edits-and-approves, the spec + four child stories land on the board, edit
distance > 0 is recorded and audited; Quill (autonomous) executes straight to the board;
destructive holds even for an autonomous seat and can be sent back. Plus the GatePolicy matrix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
263 lines
12 KiB
C#
263 lines
12 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using TeamUp.Modules.Assembler.Queue;
|
|
using TeamUp.Modules.Assembler.Runtime;
|
|
using TeamUp.SharedKernel.Access;
|
|
using TeamUp.SharedKernel.Ai;
|
|
using Xunit;
|
|
|
|
namespace TeamUp.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// M5 acceptance: Aria (gated) proposes a spec → it waits in the owner's review inbox with its
|
|
/// trace → the owner edits and approves → the artifact lands and four child tasks appear on the
|
|
/// board → edit distance is recorded. Plus: a Member cannot approve; an Autonomous agent's draft
|
|
/// executes without review; destructive ALWAYS holds; send-back works.
|
|
/// </summary>
|
|
public sealed class ReviewFlowTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
|
{
|
|
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
|
|
|
|
private sealed record AuthResponse(string Token, Guid MemberId);
|
|
|
|
private sealed record InviteResponse(Guid InvitationId, string Token);
|
|
|
|
private sealed record IdResponse(Guid Id);
|
|
|
|
private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
|
|
|
|
private sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId);
|
|
|
|
private sealed record SyncResult(int Indexed);
|
|
|
|
private sealed record RunResponse(
|
|
Guid Id, Guid SeatId, Guid WorkItemId, Guid? AgentId, string Status,
|
|
string? ActionType, string? ActionRisk, string? Prompt, string? Output, string? Error);
|
|
|
|
private sealed record ReviewItemResponse(
|
|
Guid Id, Guid OrganizationId, Guid TeamId, Guid AgentRunId, Guid AgentId, Guid WorkItemId,
|
|
string ActionKind, string Risk, string Title, string Content, List<string> ChildTitles,
|
|
string? Trace, string Status, string? Decision, double? EditDistance, DateTimeOffset CreatedAtUtc);
|
|
|
|
private sealed record TaskResponse(
|
|
Guid Id, Guid TeamId, string Title, string? Description, string Type,
|
|
string Status, string AssigneeKind, Guid? AssigneeId, Guid? ParentId);
|
|
|
|
private sealed record BoardColumn(string Status, List<TaskResponse> Items);
|
|
|
|
private sealed record BoardResponse(Guid TeamId, List<BoardColumn> Columns);
|
|
|
|
private sealed record AuditEntryResponse(
|
|
Guid Id, string Action, string EntityType, Guid EntityId,
|
|
Guid? ActorMemberId, string? Details, DateTimeOffset OccurredAtUtc);
|
|
|
|
[Fact]
|
|
public async Task Gated_run_holds_for_review_then_edit_and_approve_lands_on_the_board()
|
|
{
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
["GitSource:Provider"] = "filesystem",
|
|
["GitSource:Root"] = LocateSkillsDirectory(),
|
|
};
|
|
|
|
await using var factory = new TeamUpWebFactory(postgres.ConnectionString, settings);
|
|
using var anon = factory.CreateClient();
|
|
|
|
// --- Setup: owner, org, team, stub BYOK config, skills, Aria (gated) on a seat ---
|
|
var owner = await PostOk<BootstrapResponse>(anon, "/api/identity/bootstrap", new
|
|
{
|
|
organizationName = "AliaSaaS",
|
|
ownerEmail = "owner@alia.test",
|
|
ownerDisplayName = "Owner",
|
|
ownerPassword = "Passw0rd!",
|
|
});
|
|
using var client = Authed(factory, owner.Token);
|
|
|
|
await client.PostAsJsonAsync("/api/orgboard/organizations", new { organizationId = owner.OrganizationId, name = "AliaSaaS" });
|
|
var team = await PostOk<TeamResponse>(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "IPNOPS" });
|
|
var config = await PostOk<IdResponse>(client, "/api/integrations/api-configs", new
|
|
{
|
|
organizationId = owner.OrganizationId,
|
|
name = "Vertex-Pro",
|
|
provider = "stub",
|
|
model = "gemini-pro",
|
|
apiKey = "sk-demo-key",
|
|
});
|
|
await PostOk<SyncResult>(client, "/api/skills/sync", new { });
|
|
|
|
var seat = await PostOk<SeatResponse>(client, "/api/orgboard/seats", new { teamId = team.Id, roleName = "Product Owner" });
|
|
await client.PostAsJsonAsync($"/api/orgboard/seats/{seat.Id}/agent", new
|
|
{
|
|
name = "Aria",
|
|
monogram = "AR",
|
|
autonomy = "Gated",
|
|
apiConfigId = config.Id,
|
|
skillKeys = new[] { "spec-writing", "story-breakdown" },
|
|
docs = Array.Empty<string>(),
|
|
});
|
|
|
|
var task = await PostOk<TaskResponse>(client, "/api/orgboard/tasks", new
|
|
{
|
|
teamId = team.Id,
|
|
title = "Add a logout button to the header",
|
|
description = "Users need a way to end their session.",
|
|
type = "Spec",
|
|
});
|
|
|
|
// --- Run Aria against the task; the worker drains it ---
|
|
var run = await PostOk<RunResponse>(client, "/api/assembler/runs", new { seatId = seat.Id, workItemId = task.Id });
|
|
await DrainOneJob(factory);
|
|
var done = await client.GetFromJsonAsync<RunResponse>($"/api/assembler/runs/{run.Id}");
|
|
Assert.Equal("Completed", done!.Status);
|
|
|
|
// --- The gated draft is HELD: it waits in the review inbox with its trace ---
|
|
var pending = await client.GetFromJsonAsync<List<ReviewItemResponse>>(
|
|
$"/api/governance/reviews?organizationId={owner.OrganizationId}");
|
|
var held = Assert.Single(pending!);
|
|
Assert.Equal("Pending", held.Status);
|
|
Assert.Equal("write-spec", held.ActionKind);
|
|
Assert.Equal("Draft", held.Risk);
|
|
Assert.Equal(task.Id, held.WorkItemId);
|
|
Assert.False(string.IsNullOrWhiteSpace(held.Trace)); // the expandable reasoning trace
|
|
|
|
// --- A plain Member cannot approve (owner/team-owner capability) ---
|
|
var invite = await PostOk<InviteResponse>(client, "/api/identity/invitations", new
|
|
{
|
|
email = "dev@alia.test",
|
|
scopeType = "Organization",
|
|
scopeId = owner.OrganizationId,
|
|
role = "Member",
|
|
organizationId = owner.OrganizationId,
|
|
});
|
|
var member = await PostOk<AuthResponse>(anon, "/api/identity/invitations/accept",
|
|
new { token = invite.Token, displayName = "Dev", password = "Passw0rd!" });
|
|
using (var memberClient = Authed(factory, member.Token))
|
|
{
|
|
var forbidden = await memberClient.PostAsJsonAsync($"/api/governance/reviews/{held.Id}/approve", new { });
|
|
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
|
|
}
|
|
|
|
// --- Owner edits and approves: final spec + four child stories ---
|
|
string[] children =
|
|
[
|
|
"Add a logout button to the header (authenticated only)",
|
|
"Clear the session and redirect to /login on click",
|
|
"Hide the logout button when signed out",
|
|
"Add a regression test for protected routes after logout",
|
|
];
|
|
var approved = await PostOk<ReviewItemResponse>(client, $"/api/governance/reviews/{held.Id}/approve", new
|
|
{
|
|
content = "Spec: a logout button in the header that ends the session and returns to sign-in.",
|
|
childTitles = children,
|
|
});
|
|
Assert.Equal("Approved", approved.Status);
|
|
Assert.Equal("EditedAndApproved", approved.Decision);
|
|
Assert.NotNull(approved.EditDistance);
|
|
Assert.True(approved.EditDistance > 0, "editing before approval must record a positive edit distance");
|
|
|
|
// --- The artifact + four child tasks landed on the board ---
|
|
var board = await client.GetFromJsonAsync<BoardResponse>($"/api/orgboard/board?teamId={team.Id}");
|
|
var all = board!.Columns.SelectMany(c => c.Items).ToList();
|
|
var parent = all.Single(i => i.Id == task.Id);
|
|
Assert.Contains("Spec: a logout button", parent.Description);
|
|
var childTasks = all.Where(i => i.ParentId == task.Id).ToList();
|
|
Assert.Equal(4, childTasks.Count);
|
|
Assert.All(childTasks, c => Assert.Equal("Story", c.Type));
|
|
|
|
// --- Deciding twice is rejected ---
|
|
var again = await client.PostAsJsonAsync($"/api/governance/reviews/{held.Id}/approve", new { });
|
|
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
|
|
|
|
// --- Audit recorded the hold and the edited approval (with the metric) ---
|
|
var audit = await client.GetFromJsonAsync<List<AuditEntryResponse>>(
|
|
$"/api/governance/audit?organizationId={owner.OrganizationId}&take=200");
|
|
Assert.Contains(audit!, e => e.Action == "action.held" && e.EntityId == held.Id);
|
|
Assert.Contains(audit!, e => e.Action == "review.edited-approved"
|
|
&& e.EntityId == held.Id && (e.Details ?? "").Contains("editDistance="));
|
|
|
|
// --- An Autonomous agent's draft executes without review ---
|
|
var seatQa = await PostOk<SeatResponse>(client, "/api/orgboard/seats", new { teamId = team.Id, roleName = "QA" });
|
|
await client.PostAsJsonAsync($"/api/orgboard/seats/{seatQa.Id}/agent", new
|
|
{
|
|
name = "Quill",
|
|
monogram = "QU",
|
|
autonomy = "Autonomous",
|
|
apiConfigId = config.Id,
|
|
skillKeys = new[] { "test-plan-generation", "diff-review" },
|
|
docs = Array.Empty<string>(),
|
|
});
|
|
var qaTask = await PostOk<TaskResponse>(client, "/api/orgboard/tasks", new
|
|
{
|
|
teamId = team.Id,
|
|
title = "QA the logout flow",
|
|
description = (string?)null,
|
|
type = "Test",
|
|
});
|
|
await PostOk<RunResponse>(client, "/api/assembler/runs", new { seatId = seatQa.Id, workItemId = qaTask.Id });
|
|
await DrainOneJob(factory);
|
|
|
|
var pendingAfter = await client.GetFromJsonAsync<List<ReviewItemResponse>>(
|
|
$"/api/governance/reviews?organizationId={owner.OrganizationId}");
|
|
Assert.Empty(pendingAfter!); // nothing new held
|
|
|
|
var boardAfter = await client.GetFromJsonAsync<BoardResponse>($"/api/orgboard/board?teamId={team.Id}");
|
|
var qaParent = boardAfter!.Columns.SelectMany(c => c.Items).Single(i => i.Id == qaTask.Id);
|
|
Assert.Contains("[stub", qaParent.Description); // the artifact executed straight onto the task
|
|
|
|
// --- Destructive ALWAYS holds, even for an Autonomous seat — and send-back works ---
|
|
await using (var scope = factory.Services.CreateAsyncScope())
|
|
{
|
|
var gate = scope.ServiceProvider.GetRequiredService<IActionGate>();
|
|
var result = await gate.EvaluateAsync(new AgentActionProposal(
|
|
Guid.NewGuid(), seatQa.Id, seatQa.AgentId ?? Guid.NewGuid(), qaTask.Id,
|
|
team.Id, owner.OrganizationId, Autonomy.Autonomous,
|
|
"delete-branch", "Destructive", "Delete the release branch", "rm -rf …", [], null));
|
|
Assert.Equal(GateOutcome.Held, result.Outcome);
|
|
Assert.NotNull(result.ReviewItemId);
|
|
|
|
var sentBack = await PostOk<ReviewItemResponse>(
|
|
client, $"/api/governance/reviews/{result.ReviewItemId}/sendback", new { });
|
|
Assert.Equal("SentBack", sentBack.Status);
|
|
}
|
|
}
|
|
|
|
private static async Task DrainOneJob(TeamUpWebFactory factory)
|
|
{
|
|
await using var scope = factory.Services.CreateAsyncScope();
|
|
var queue = scope.ServiceProvider.GetRequiredService<JobQueue>();
|
|
var job = await queue.ClaimNextAsync("test-worker");
|
|
Assert.NotNull(job);
|
|
await scope.ServiceProvider.GetRequiredService<AgentRunExecutor>().ProcessAsync(job!);
|
|
}
|
|
|
|
private static HttpClient Authed(TeamUpWebFactory factory, string token)
|
|
{
|
|
var client = factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
return client;
|
|
}
|
|
|
|
private static async Task<T> PostOk<T>(HttpClient client, string url, object body)
|
|
{
|
|
var response = await client.PostAsJsonAsync(url, body);
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var value = await response.Content.ReadFromJsonAsync<T>();
|
|
Assert.NotNull(value);
|
|
return value!;
|
|
}
|
|
|
|
private static string LocateSkillsDirectory()
|
|
{
|
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "TeamUp.slnx")))
|
|
{
|
|
dir = dir.Parent;
|
|
}
|
|
|
|
Assert.NotNull(dir);
|
|
return Path.Combine(dir!.FullName, "skills");
|
|
}
|
|
}
|