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:
soroush.asadi
2026-06-10 07:45:35 +03:30
parent b5ce7a31de
commit d83ad87151
20 changed files with 1070 additions and 2 deletions
@@ -12,7 +12,8 @@ internal sealed record AgentRunPayload(Guid RunId);
/// <summary> /// <summary>
/// Processes one claimed job end to end: resolve the run context (OrgBoard) + skills (Skills) → /// 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, /// 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> /// </summary>
internal sealed class AgentRunExecutor( internal sealed class AgentRunExecutor(
AssemblerDbContext db, AssemblerDbContext db,
@@ -20,6 +21,7 @@ internal sealed class AgentRunExecutor(
ISkillCatalog skillCatalog, ISkillCatalog skillCatalog,
IApiConfigResolver configResolver, IApiConfigResolver configResolver,
IModelClient modelClient, IModelClient modelClient,
IActionGate actionGate,
TimeProvider clock, TimeProvider clock,
ILogger<AgentRunExecutor> logger) ILogger<AgentRunExecutor> logger)
{ {
@@ -68,7 +70,21 @@ internal sealed class AgentRunExecutor(
skill = context.SkillKeys.Count > 0 ? context.SkillKeys[0] : null, 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()); job.MarkDone(clock.GetUtcNow());
await db.SaveChangesAsync(cancellationToken); 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.Http;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.Governance.Domain;
using TeamUp.Modules.Governance.Gate;
using TeamUp.Modules.Governance.Persistence; using TeamUp.Modules.Governance.Persistence;
using TeamUp.SharedKernel.Access; using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Metrics;
using TeamUp.SharedKernel.Modularity; using TeamUp.SharedKernel.Modularity;
namespace TeamUp.Modules.Governance.Endpoints; namespace TeamUp.Modules.Governance.Endpoints;
@@ -17,6 +21,26 @@ internal sealed record AuditEntryResponse(
string? Details, string? Details,
DateTimeOffset OccurredAtUtc); 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 internal static class GovernanceEndpoints
{ {
public static void Map(IEndpointRouteBuilder endpoints) public static void Map(IEndpointRouteBuilder endpoints)
@@ -25,8 +49,16 @@ internal static class GovernanceEndpoints
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("governance"))); group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("governance")));
group.MapGet("/audit", GetAudit).RequireAuthorization(); 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( private static async Task<IResult> GetAudit(
Guid organizationId, int? take, IPermissionService permissions, GovernanceDbContext db, CancellationToken ct) Guid organizationId, int? take, IPermissionService permissions, GovernanceDbContext db, CancellationToken ct)
{ {
@@ -46,4 +78,102 @@ internal static class GovernanceEndpoints
return Results.Ok(entries); 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 Microsoft.Extensions.DependencyInjection.Extensions;
using TeamUp.Modules.Governance.Auditing; using TeamUp.Modules.Governance.Auditing;
using TeamUp.Modules.Governance.Endpoints; using TeamUp.Modules.Governance.Endpoints;
using TeamUp.Modules.Governance.Gate;
using TeamUp.Modules.Governance.Persistence; using TeamUp.Modules.Governance.Persistence;
using TeamUp.SharedKernel.Ai;
using TeamUp.SharedKernel.Auditing; using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Modularity; using TeamUp.SharedKernel.Modularity;
using TeamUp.SharedKernel.Persistence; using TeamUp.SharedKernel.Persistence;
@@ -25,6 +27,8 @@ public sealed class GovernanceModule : IModule
services.AddDbContext<GovernanceDbContext>(options => options.UseNpgsql(connectionString)); services.AddDbContext<GovernanceDbContext>(options => options.UseNpgsql(connectionString));
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<GovernanceDbContext>()); services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<GovernanceDbContext>());
services.AddScoped<IAuditLog, AuditLog>(); services.AddScoped<IAuditLog, AuditLog>();
services.AddScoped<HeldActionExecutor>();
services.AddScoped<IActionGate, ActionGate>();
services.TryAddSingleton(TimeProvider.System); services.TryAddSingleton(TimeProvider.System);
} }
@@ -8,6 +8,7 @@ internal sealed class GovernanceDbContext(DbContextOptions<GovernanceDbContext>
: DbContext(options), IModuleDbContext : DbContext(options), IModuleDbContext
{ {
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>(); public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
public DbSet<ReviewItem> ReviewItems => Set<ReviewItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -23,5 +24,18 @@ internal sealed class GovernanceDbContext(DbContextOptions<GovernanceDbContext>
entry.HasIndex(a => a.OccurredAtUtc); entry.HasIndex(a => a.OccurredAtUtc);
entry.HasIndex(a => new { a.EntityType, a.EntityId }); 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);
});
} }
} }
@@ -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
}
}
}
@@ -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");
}
}
}
@@ -1,5 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -60,6 +61,86 @@ namespace TeamUp.Modules.Governance.Persistence.Migrations
b.ToTable("audit_entries", "governance"); 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 #pragma warning restore 612, 618
} }
} }
@@ -61,4 +61,13 @@ internal sealed class WorkItem : Entity
AssigneeId = null; AssigneeId = null;
UpdatedAtUtc = nowUtc; 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.Persistence;
using TeamUp.Modules.OrgBoard.Runtime; using TeamUp.Modules.OrgBoard.Runtime;
using TeamUp.SharedKernel.Ai; using TeamUp.SharedKernel.Ai;
using TeamUp.SharedKernel.Board;
using TeamUp.SharedKernel.Modularity; using TeamUp.SharedKernel.Modularity;
using TeamUp.SharedKernel.Persistence; using TeamUp.SharedKernel.Persistence;
@@ -25,6 +26,7 @@ public sealed class OrgBoardModule : IModule
services.AddDbContext<OrgBoardDbContext>(options => options.UseNpgsql(connectionString)); services.AddDbContext<OrgBoardDbContext>(options => options.UseNpgsql(connectionString));
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<OrgBoardDbContext>()); services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<OrgBoardDbContext>());
services.AddScoped<IAgentRunContextProvider, AgentRunContextProvider>(); services.AddScoped<IAgentRunContextProvider, AgentRunContextProvider>();
services.AddScoped<IBoardWriter, BoardWriter>();
services.TryAddSingleton(TimeProvider.System); 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");
}
}