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>
/// 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);
});
}
}
@@ -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 />
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);
}
}