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>
|
/// <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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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 />
|
// <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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user