M5: action gate + review inbox — edit distance captured for real
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>
This commit is contained in:
@@ -12,7 +12,8 @@ internal sealed record AgentRunPayload(Guid RunId);
|
||||
/// <summary>
|
||||
/// Processes one claimed job end to end: resolve the run context (OrgBoard) + skills (Skills) →
|
||||
/// assemble the prompt → call the model (BYOK, with fallback) → parse into an action + risk tag,
|
||||
/// all captured on the AgentRun. Nothing executes off the parsed action — the gate is M5.
|
||||
/// all captured on the AgentRun — then hand the proposal to the action gate (Governance), which
|
||||
/// executes it or holds it in the review inbox.
|
||||
/// </summary>
|
||||
internal sealed class AgentRunExecutor(
|
||||
AssemblerDbContext db,
|
||||
@@ -20,6 +21,7 @@ internal sealed class AgentRunExecutor(
|
||||
ISkillCatalog skillCatalog,
|
||||
IApiConfigResolver configResolver,
|
||||
IModelClient modelClient,
|
||||
IActionGate actionGate,
|
||||
TimeProvider clock,
|
||||
ILogger<AgentRunExecutor> logger)
|
||||
{
|
||||
@@ -68,7 +70,21 @@ internal sealed class AgentRunExecutor(
|
||||
skill = context.SkillKeys.Count > 0 ? context.SkillKeys[0] : null,
|
||||
});
|
||||
|
||||
run.Complete(completion.Text ?? string.Empty, assembled.PrimaryAction, assembled.PrimaryActionRisk, result, completion.LatencyMs, clock.GetUtcNow());
|
||||
var output = completion.Text ?? string.Empty;
|
||||
run.Complete(output, assembled.PrimaryAction, assembled.PrimaryActionRisk, result, completion.LatencyMs, clock.GetUtcNow());
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Hand the parsed action to the gate: autonomy vs risk → execute now or hold in review.
|
||||
var gate = await actionGate.EvaluateAsync(
|
||||
new AgentActionProposal(
|
||||
run.Id, run.SeatId, context.AgentId, run.WorkItemId, context.TeamId, context.OrganizationId,
|
||||
context.Autonomy, assembled.PrimaryAction, assembled.PrimaryActionRisk,
|
||||
context.TaskTitle, output, OutputParser.ExtractChildTitles(output), assembled.Trace),
|
||||
cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Run {RunId}: {Action} ({Risk}) → {Outcome}.",
|
||||
run.Id, assembled.PrimaryAction, assembled.PrimaryActionRisk, gate.Outcome);
|
||||
|
||||
job.MarkDone(clock.GetUtcNow());
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace TeamUp.Modules.Assembler.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts proposed child-task titles from model output: top-level numbered list items
|
||||
/// ("1. …" / "2) …"). Deterministic and conservative — anything unparsed simply yields no
|
||||
/// children, and the reviewer can add/edit them in the review inbox before approving.
|
||||
/// </summary>
|
||||
internal static partial class OutputParser
|
||||
{
|
||||
private const int MaxChildren = 10;
|
||||
private const int MaxTitleLength = 300;
|
||||
|
||||
[GeneratedRegex(@"^\s*\d{1,2}[\.\)]\s+(?<title>.+?)\s*$", RegexOptions.Multiline)]
|
||||
private static partial Regex NumberedLine();
|
||||
|
||||
public static IReadOnlyList<string> ExtractChildTitles(string output) =>
|
||||
NumberedLine().Matches(output)
|
||||
.Select(match => match.Groups["title"].Value.Trim())
|
||||
.Where(title => title.Length > 0)
|
||||
.Take(MaxChildren)
|
||||
.Select(title => title.Length > MaxTitleLength ? title[..MaxTitleLength] : title)
|
||||
.ToList();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using TeamUp.SharedKernel.Ai;
|
||||
using TeamUp.SharedKernel.Domain;
|
||||
|
||||
namespace TeamUp.Modules.Governance.Domain;
|
||||
|
||||
internal enum ReviewStatus
|
||||
{
|
||||
Pending,
|
||||
Approved,
|
||||
SentBack,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A held agent action waiting in the review inbox. Carries the proposed artifact (editable) and
|
||||
/// the reasoning trace; on approval it records the human edit distance — the north-star metric.
|
||||
/// </summary>
|
||||
internal sealed class ReviewItem : Entity
|
||||
{
|
||||
public Guid OrganizationId { get; private set; }
|
||||
public Guid TeamId { get; private set; }
|
||||
public Guid AgentRunId { get; private set; }
|
||||
public Guid SeatId { get; private set; }
|
||||
public Guid AgentId { get; private set; }
|
||||
public Guid WorkItemId { get; private set; }
|
||||
public string ActionKind { get; private set; } = null!;
|
||||
public string Risk { get; private set; } = null!;
|
||||
public string Title { get; private set; } = null!;
|
||||
public string Content { get; private set; } = null!;
|
||||
public List<string> ChildTitles { get; private set; } = [];
|
||||
public string? Trace { get; private set; }
|
||||
public ReviewStatus Status { get; private set; }
|
||||
public string? Decision { get; private set; }
|
||||
public double? EditDistance { get; private set; }
|
||||
public Guid? DecidedByMemberId { get; private set; }
|
||||
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||
public DateTimeOffset? DecidedAtUtc { get; private set; }
|
||||
|
||||
private ReviewItem()
|
||||
{
|
||||
}
|
||||
|
||||
public ReviewItem(AgentActionProposal proposal, DateTimeOffset createdAtUtc)
|
||||
{
|
||||
OrganizationId = proposal.OrganizationId;
|
||||
TeamId = proposal.TeamId;
|
||||
AgentRunId = proposal.AgentRunId;
|
||||
SeatId = proposal.SeatId;
|
||||
AgentId = proposal.AgentId;
|
||||
WorkItemId = proposal.WorkItemId;
|
||||
ActionKind = proposal.ActionKind;
|
||||
Risk = proposal.Risk;
|
||||
Title = proposal.Title;
|
||||
Content = proposal.Content;
|
||||
ChildTitles = proposal.ChildTitles.ToList();
|
||||
Trace = proposal.Trace;
|
||||
Status = ReviewStatus.Pending;
|
||||
CreatedAtUtc = createdAtUtc;
|
||||
}
|
||||
|
||||
public void Approve(string finalContent, List<string> finalChildTitles, double editDistance, bool edited, Guid memberId, DateTimeOffset nowUtc)
|
||||
{
|
||||
Content = finalContent;
|
||||
ChildTitles = finalChildTitles;
|
||||
EditDistance = editDistance;
|
||||
Status = ReviewStatus.Approved;
|
||||
Decision = edited ? "EditedAndApproved" : "Approved";
|
||||
DecidedByMemberId = memberId;
|
||||
DecidedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
public void SendBack(Guid memberId, DateTimeOffset nowUtc)
|
||||
{
|
||||
Status = ReviewStatus.SentBack;
|
||||
Decision = "SentBack";
|
||||
DecidedByMemberId = memberId;
|
||||
DecidedAtUtc = nowUtc;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,12 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeamUp.Modules.Governance.Domain;
|
||||
using TeamUp.Modules.Governance.Gate;
|
||||
using TeamUp.Modules.Governance.Persistence;
|
||||
using TeamUp.SharedKernel.Access;
|
||||
using TeamUp.SharedKernel.Auditing;
|
||||
using TeamUp.SharedKernel.Metrics;
|
||||
using TeamUp.SharedKernel.Modularity;
|
||||
|
||||
namespace TeamUp.Modules.Governance.Endpoints;
|
||||
@@ -17,6 +21,26 @@ internal sealed record AuditEntryResponse(
|
||||
string? Details,
|
||||
DateTimeOffset OccurredAtUtc);
|
||||
|
||||
internal 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);
|
||||
|
||||
internal sealed record ApproveRequest(string? Content, List<string>? ChildTitles);
|
||||
|
||||
internal static class GovernanceEndpoints
|
||||
{
|
||||
public static void Map(IEndpointRouteBuilder endpoints)
|
||||
@@ -25,8 +49,16 @@ internal static class GovernanceEndpoints
|
||||
|
||||
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("governance")));
|
||||
group.MapGet("/audit", GetAudit).RequireAuthorization();
|
||||
group.MapGet("/reviews", ListReviews).RequireAuthorization();
|
||||
group.MapPost("/reviews/{id:guid}/approve", Approve).RequireAuthorization();
|
||||
group.MapPost("/reviews/{id:guid}/sendback", SendBack).RequireAuthorization();
|
||||
}
|
||||
|
||||
private static ReviewItemResponse ToResponse(ReviewItem item) => new(
|
||||
item.Id, item.OrganizationId, item.TeamId, item.AgentRunId, item.AgentId, item.WorkItemId,
|
||||
item.ActionKind, item.Risk, item.Title, item.Content, item.ChildTitles, item.Trace,
|
||||
item.Status.ToString(), item.Decision, item.EditDistance, item.CreatedAtUtc);
|
||||
|
||||
private static async Task<IResult> GetAudit(
|
||||
Guid organizationId, int? take, IPermissionService permissions, GovernanceDbContext db, CancellationToken ct)
|
||||
{
|
||||
@@ -46,4 +78,102 @@ internal static class GovernanceEndpoints
|
||||
|
||||
return Results.Ok(entries);
|
||||
}
|
||||
|
||||
// The review inbox = the Approvals section of an approver's cartable. Items are filtered to
|
||||
// the scopes where the caller may approve (org owner sees all; a team owner their teams).
|
||||
private static async Task<IResult> ListReviews(
|
||||
Guid organizationId, string? status, IPermissionService permissions,
|
||||
GovernanceDbContext db, CancellationToken ct)
|
||||
{
|
||||
var wanted = Enum.TryParse<ReviewStatus>(status, ignoreCase: true, out var parsed)
|
||||
? parsed
|
||||
: ReviewStatus.Pending;
|
||||
|
||||
var items = await db.ReviewItems
|
||||
.Where(r => r.OrganizationId == organizationId && r.Status == wanted)
|
||||
.OrderByDescending(r => r.CreatedAtUtc)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var visible = items
|
||||
.Where(r => permissions.Has(
|
||||
Capability.ApproveHeldActions, ScopeRef.Team(r.TeamId), ScopeRef.Org(r.OrganizationId)))
|
||||
.Select(ToResponse)
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(visible);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Approve(
|
||||
Guid id, ApproveRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
HeldActionExecutor executor, IAuditLog audit, GovernanceDbContext db,
|
||||
TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
var item = await db.ReviewItems.FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||
if (item is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.ApproveHeldActions, ScopeRef.Team(item.TeamId), ScopeRef.Org(item.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
if (item.Status != ReviewStatus.Pending)
|
||||
{
|
||||
return Results.Conflict("This item has already been decided.");
|
||||
}
|
||||
|
||||
var finalContent = request.Content ?? item.Content;
|
||||
var finalChildren = request.ChildTitles ?? item.ChildTitles;
|
||||
|
||||
// Human edit distance — the north-star metric — over the full editable artifact.
|
||||
var original = item.Content + "\n" + string.Join("\n", item.ChildTitles);
|
||||
var final = finalContent + "\n" + string.Join("\n", finalChildren);
|
||||
var distance = EditDistance.Normalized(original, final);
|
||||
var edited = distance > 0;
|
||||
|
||||
item.Approve(finalContent, finalChildren.ToList(), distance, edited, user.MemberId, clock.GetUtcNow());
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Execute the approved action onto the board (artifact + child tasks).
|
||||
await executor.ExecuteAsync(item.TeamId, item.WorkItemId, finalContent, finalChildren, user.MemberId, ct);
|
||||
|
||||
await audit.WriteAsync(
|
||||
new AuditEvent(
|
||||
edited ? "review.edited-approved" : "review.approved",
|
||||
"ReviewItem", item.Id, user.MemberId,
|
||||
$"{item.ActionKind} editDistance={distance:F3} children={finalChildren.Count}"),
|
||||
ct);
|
||||
|
||||
return Results.Ok(ToResponse(item));
|
||||
}
|
||||
|
||||
private static async Task<IResult> SendBack(
|
||||
Guid id, ICurrentUser user, IPermissionService permissions,
|
||||
IAuditLog audit, GovernanceDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
var item = await db.ReviewItems.FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||
if (item is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.ApproveHeldActions, ScopeRef.Team(item.TeamId), ScopeRef.Org(item.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
if (item.Status != ReviewStatus.Pending)
|
||||
{
|
||||
return Results.Conflict("This item has already been decided.");
|
||||
}
|
||||
|
||||
item.SendBack(user.MemberId, clock.GetUtcNow());
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(
|
||||
new AuditEvent("review.sentback", "ReviewItem", item.Id, user.MemberId, item.ActionKind), ct);
|
||||
|
||||
return Results.Ok(ToResponse(item));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using TeamUp.Modules.Governance.Domain;
|
||||
using TeamUp.Modules.Governance.Persistence;
|
||||
using TeamUp.SharedKernel.Access;
|
||||
using TeamUp.SharedKernel.Ai;
|
||||
using TeamUp.SharedKernel.Auditing;
|
||||
|
||||
namespace TeamUp.Modules.Governance.Gate;
|
||||
|
||||
/// <summary>
|
||||
/// The action gate: compares the seat's autonomy to the action's risk. Execute now (autonomous +
|
||||
/// non-destructive) or hold as a <see cref="ReviewItem"/> in the review inbox. Every decision is
|
||||
/// audited. Destructive always holds — GatePolicy is the backstop.
|
||||
/// </summary>
|
||||
internal sealed class ActionGate(
|
||||
GovernanceDbContext db,
|
||||
HeldActionExecutor executor,
|
||||
IAuditLog audit,
|
||||
TimeProvider clock) : IActionGate
|
||||
{
|
||||
public async Task<GateResult> EvaluateAsync(AgentActionProposal proposal, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var risk = Enum.TryParse<ActionRisk>(proposal.Risk, ignoreCase: true, out var parsed)
|
||||
? parsed
|
||||
: ActionRisk.Draft; // unknown risk is treated as Draft → held unless autonomous
|
||||
|
||||
if (GatePolicy.ShouldHold(proposal.Autonomy, risk))
|
||||
{
|
||||
var item = new ReviewItem(proposal, clock.GetUtcNow());
|
||||
db.ReviewItems.Add(item);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await audit.WriteAsync(
|
||||
new AuditEvent("action.held", "ReviewItem", item.Id, null,
|
||||
$"{proposal.ActionKind} ({proposal.Risk}) by agent {proposal.AgentId}"),
|
||||
cancellationToken);
|
||||
return new GateResult(GateOutcome.Held, item.Id);
|
||||
}
|
||||
|
||||
await executor.ExecuteAsync(
|
||||
proposal.TeamId, proposal.WorkItemId, proposal.Content, proposal.ChildTitles,
|
||||
actedByMemberId: null, cancellationToken);
|
||||
await audit.WriteAsync(
|
||||
new AuditEvent("action.executed", "AgentRun", proposal.AgentRunId, null,
|
||||
$"{proposal.ActionKind} ({proposal.Risk}) autonomous"),
|
||||
cancellationToken);
|
||||
return new GateResult(GateOutcome.Executed, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using TeamUp.SharedKernel.Board;
|
||||
|
||||
namespace TeamUp.Modules.Governance.Gate;
|
||||
|
||||
/// <summary>
|
||||
/// Performs the internal action behind an agent proposal: write the artifact onto the task and
|
||||
/// create the proposed child tasks. Used by the gate (autonomous path) and the approve endpoint.
|
||||
/// </summary>
|
||||
internal sealed class HeldActionExecutor(IBoardWriter boardWriter)
|
||||
{
|
||||
public async Task ExecuteAsync(
|
||||
Guid teamId,
|
||||
Guid workItemId,
|
||||
string content,
|
||||
IReadOnlyList<string> childTitles,
|
||||
Guid? actedByMemberId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
await boardWriter.AttachArtifactAsync(workItemId, content, cancellationToken);
|
||||
}
|
||||
|
||||
if (childTitles.Count > 0)
|
||||
{
|
||||
var children = childTitles.Select(title => new ChildTaskSpec(title, "Story")).ToList();
|
||||
await boardWriter.CreateChildTasksAsync(teamId, workItemId, children, actedByMemberId, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using TeamUp.Modules.Governance.Auditing;
|
||||
using TeamUp.Modules.Governance.Endpoints;
|
||||
using TeamUp.Modules.Governance.Gate;
|
||||
using TeamUp.Modules.Governance.Persistence;
|
||||
using TeamUp.SharedKernel.Ai;
|
||||
using TeamUp.SharedKernel.Auditing;
|
||||
using TeamUp.SharedKernel.Modularity;
|
||||
using TeamUp.SharedKernel.Persistence;
|
||||
@@ -25,6 +27,8 @@ public sealed class GovernanceModule : IModule
|
||||
services.AddDbContext<GovernanceDbContext>(options => options.UseNpgsql(connectionString));
|
||||
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<GovernanceDbContext>());
|
||||
services.AddScoped<IAuditLog, AuditLog>();
|
||||
services.AddScoped<HeldActionExecutor>();
|
||||
services.AddScoped<IActionGate, ActionGate>();
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ internal sealed class GovernanceDbContext(DbContextOptions<GovernanceDbContext>
|
||||
: DbContext(options), IModuleDbContext
|
||||
{
|
||||
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
|
||||
public DbSet<ReviewItem> ReviewItems => Set<ReviewItem>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -23,5 +24,18 @@ internal sealed class GovernanceDbContext(DbContextOptions<GovernanceDbContext>
|
||||
entry.HasIndex(a => a.OccurredAtUtc);
|
||||
entry.HasIndex(a => new { a.EntityType, a.EntityId });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ReviewItem>(item =>
|
||||
{
|
||||
item.ToTable("review_items");
|
||||
item.HasKey(r => r.Id);
|
||||
item.Property(r => r.ActionKind).HasMaxLength(60).IsRequired();
|
||||
item.Property(r => r.Risk).HasMaxLength(20).IsRequired();
|
||||
item.Property(r => r.Title).HasMaxLength(300).IsRequired();
|
||||
item.Property(r => r.Status).HasConversion<string>().HasMaxLength(20);
|
||||
item.Property(r => r.Decision).HasMaxLength(30);
|
||||
item.HasIndex(r => new { r.OrganizationId, r.Status });
|
||||
item.HasIndex(r => r.AgentRunId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TeamUp.Modules.Governance.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.Governance.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(GovernanceDbContext))]
|
||||
[Migration("20260610041006_AddReviewItems")]
|
||||
partial class AddReviewItems
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("governance")
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.Governance.Domain.AuditEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("ActorMemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Details")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("EntityType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset>("OccurredAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OccurredAtUtc");
|
||||
|
||||
b.HasIndex("EntityType", "EntityId");
|
||||
|
||||
b.ToTable("audit_entries", "governance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.Governance.Domain.ReviewItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ActionKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<Guid>("AgentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AgentRunId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("ChildTitles")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DecidedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DecidedByMemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Decision")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<double?>("EditDistance")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Risk")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<Guid>("SeatId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<Guid>("TeamId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("Trace")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("WorkItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AgentRunId");
|
||||
|
||||
b.HasIndex("OrganizationId", "Status");
|
||||
|
||||
b.ToTable("review_items", "governance");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.Governance.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReviewItems : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "review_items",
|
||||
schema: "governance",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TeamId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AgentRunId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SeatId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AgentId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
WorkItemId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ActionKind = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
|
||||
Risk = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
|
||||
Content = table.Column<string>(type: "text", nullable: false),
|
||||
ChildTitles = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||
Trace = table.Column<string>(type: "text", nullable: true),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
Decision = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
|
||||
EditDistance = table.Column<double>(type: "double precision", nullable: true),
|
||||
DecidedByMemberId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
DecidedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_review_items", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_review_items_AgentRunId",
|
||||
schema: "governance",
|
||||
table: "review_items",
|
||||
column: "AgentRunId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_review_items_OrganizationId_Status",
|
||||
schema: "governance",
|
||||
table: "review_items",
|
||||
columns: new[] { "OrganizationId", "Status" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "review_items",
|
||||
schema: "governance");
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -1,5 +1,6 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
@@ -60,6 +61,86 @@ namespace TeamUp.Modules.Governance.Persistence.Migrations
|
||||
|
||||
b.ToTable("audit_entries", "governance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.Governance.Domain.ReviewItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ActionKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<Guid>("AgentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AgentRunId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("ChildTitles")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DecidedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DecidedByMemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Decision")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<double?>("EditDistance")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Risk")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<Guid>("SeatId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<Guid>("TeamId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("Trace")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("WorkItemId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AgentRunId");
|
||||
|
||||
b.HasIndex("OrganizationId", "Status");
|
||||
|
||||
b.ToTable("review_items", "governance");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,4 +61,13 @@ internal sealed class WorkItem : Entity
|
||||
AssigneeId = null;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
/// <summary>Appends an approved agent artifact (spec / test plan) to the task.</summary>
|
||||
public void AttachArtifact(string content, DateTimeOffset nowUtc)
|
||||
{
|
||||
Description = string.IsNullOrWhiteSpace(Description)
|
||||
? content
|
||||
: Description + "\n\n---\n\n" + content;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using TeamUp.Modules.OrgBoard.Endpoints;
|
||||
using TeamUp.Modules.OrgBoard.Persistence;
|
||||
using TeamUp.Modules.OrgBoard.Runtime;
|
||||
using TeamUp.SharedKernel.Ai;
|
||||
using TeamUp.SharedKernel.Board;
|
||||
using TeamUp.SharedKernel.Modularity;
|
||||
using TeamUp.SharedKernel.Persistence;
|
||||
|
||||
@@ -25,6 +26,7 @@ public sealed class OrgBoardModule : IModule
|
||||
services.AddDbContext<OrgBoardDbContext>(options => options.UseNpgsql(connectionString));
|
||||
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<OrgBoardDbContext>());
|
||||
services.AddScoped<IAgentRunContextProvider, AgentRunContextProvider>();
|
||||
services.AddScoped<IBoardWriter, BoardWriter>();
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeamUp.Modules.OrgBoard.Domain;
|
||||
using TeamUp.Modules.OrgBoard.Persistence;
|
||||
using TeamUp.SharedKernel.Board;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Runtime;
|
||||
|
||||
/// <summary>Writes approved agent actions onto the board — creating child tasks under a parent.</summary>
|
||||
internal sealed class BoardWriter(OrgBoardDbContext db, TimeProvider clock) : IBoardWriter
|
||||
{
|
||||
public async Task<int> CreateChildTasksAsync(
|
||||
Guid teamId,
|
||||
Guid parentWorkItemId,
|
||||
IReadOnlyList<ChildTaskSpec> children,
|
||||
Guid? createdByMemberId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
var creator = createdByMemberId ?? Guid.Empty;
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
var type = Enum.TryParse<WorkItemType>(child.Type, ignoreCase: true, out var parsed)
|
||||
? parsed
|
||||
: WorkItemType.Story;
|
||||
db.WorkItems.Add(new WorkItem(teamId, child.Title, description: null, type, creator, now, parentWorkItemId));
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return children.Count;
|
||||
}
|
||||
|
||||
public async Task AttachArtifactAsync(Guid workItemId, string content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var item = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == workItemId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Work item {workItemId} not found.");
|
||||
|
||||
item.AttachArtifact(content, clock.GetUtcNow());
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TeamUp.SharedKernel.Access;
|
||||
|
||||
/// <summary>
|
||||
/// Risk lives on the action, not the agent. The action gate (Governance, M5) compares it to the
|
||||
/// seat's autonomy to decide execute-vs-hold. Destructive always holds for a human.
|
||||
/// </summary>
|
||||
public enum ActionRisk
|
||||
{
|
||||
Read,
|
||||
Draft,
|
||||
Publish,
|
||||
Destructive,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace TeamUp.SharedKernel.Access;
|
||||
|
||||
/// <summary>
|
||||
/// The action-gate decision matrix: seat autonomy × action risk → execute or hold. Pure policy —
|
||||
/// persistence/execution live in Governance. Destructive ALWAYS holds for a human, whatever the
|
||||
/// autonomy (the prompt-injection backstop). Read-level actions never hold. Draft/Publish execute
|
||||
/// only on an Autonomous seat; DraftOnly and Gated behave alike on V1's internal action set (the
|
||||
/// distinction becomes meaningful with per-agent MCP tool-calls in Phase 1).
|
||||
/// </summary>
|
||||
public static class GatePolicy
|
||||
{
|
||||
public static bool ShouldHold(Autonomy autonomy, ActionRisk risk) => risk switch
|
||||
{
|
||||
ActionRisk.Read => false,
|
||||
ActionRisk.Destructive => true,
|
||||
_ => autonomy != Autonomy.Autonomous,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using TeamUp.SharedKernel.Access;
|
||||
|
||||
namespace TeamUp.SharedKernel.Ai;
|
||||
|
||||
/// <summary>An agent's proposed action coming off a completed run, handed to the action gate.</summary>
|
||||
public sealed record AgentActionProposal(
|
||||
Guid AgentRunId,
|
||||
Guid SeatId,
|
||||
Guid AgentId,
|
||||
Guid WorkItemId,
|
||||
Guid TeamId,
|
||||
Guid OrganizationId,
|
||||
Autonomy Autonomy,
|
||||
string ActionKind,
|
||||
string Risk,
|
||||
string Title,
|
||||
string Content,
|
||||
IReadOnlyList<string> ChildTitles,
|
||||
string? Trace);
|
||||
|
||||
public enum GateOutcome
|
||||
{
|
||||
Executed,
|
||||
Held,
|
||||
}
|
||||
|
||||
public sealed record GateResult(GateOutcome Outcome, Guid? ReviewItemId);
|
||||
|
||||
/// <summary>
|
||||
/// The action gate: autonomy vs risk → execute now or hold for review. Implemented by Governance;
|
||||
/// called by the assembler when a run completes. Destructive always holds.
|
||||
/// </summary>
|
||||
public interface IActionGate
|
||||
{
|
||||
Task<GateResult> EvaluateAsync(AgentActionProposal proposal, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace TeamUp.SharedKernel.Board;
|
||||
|
||||
public sealed record ChildTaskSpec(string Title, string Type);
|
||||
|
||||
/// <summary>
|
||||
/// Lets Governance execute an approved agent action onto the board (create the child tasks)
|
||||
/// without referencing OrgBoard's tables. Implemented by OrgBoard.
|
||||
/// </summary>
|
||||
public interface IBoardWriter
|
||||
{
|
||||
Task<int> CreateChildTasksAsync(
|
||||
Guid teamId,
|
||||
Guid parentWorkItemId,
|
||||
IReadOnlyList<ChildTaskSpec> children,
|
||||
Guid? createdByMemberId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Writes an approved artifact (spec / test plan) onto the work item.</summary>
|
||||
Task AttachArtifactAsync(Guid workItemId, string content, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using TeamUp.SharedKernel.Access;
|
||||
using Xunit;
|
||||
|
||||
namespace TeamUp.IntegrationTests;
|
||||
|
||||
/// <summary>The gate decision matrix — pure policy, no database.</summary>
|
||||
public sealed class GatePolicyTests
|
||||
{
|
||||
[Theory]
|
||||
// Read never holds.
|
||||
[InlineData(Autonomy.DraftOnly, ActionRisk.Read, false)]
|
||||
[InlineData(Autonomy.Gated, ActionRisk.Read, false)]
|
||||
[InlineData(Autonomy.Autonomous, ActionRisk.Read, false)]
|
||||
// Draft/Publish hold unless the seat is Autonomous.
|
||||
[InlineData(Autonomy.DraftOnly, ActionRisk.Draft, true)]
|
||||
[InlineData(Autonomy.Gated, ActionRisk.Draft, true)]
|
||||
[InlineData(Autonomy.Autonomous, ActionRisk.Draft, false)]
|
||||
[InlineData(Autonomy.Gated, ActionRisk.Publish, true)]
|
||||
[InlineData(Autonomy.Autonomous, ActionRisk.Publish, false)]
|
||||
// Destructive ALWAYS holds — whatever the autonomy. The backstop.
|
||||
[InlineData(Autonomy.DraftOnly, ActionRisk.Destructive, true)]
|
||||
[InlineData(Autonomy.Gated, ActionRisk.Destructive, true)]
|
||||
[InlineData(Autonomy.Autonomous, ActionRisk.Destructive, true)]
|
||||
public void Gate_matrix(Autonomy autonomy, ActionRisk risk, bool shouldHold) =>
|
||||
Assert.Equal(shouldHold, GatePolicy.ShouldHold(autonomy, risk));
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user