diff --git a/README.md b/README.md index dceafa3..b20541e 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,10 @@ For the full staged roadmap, including contribution points, revenue share, patro - set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in - set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path +- set `Bridge:LocalBridgeToken` to allow the transitional local bridge actuator to register and receipt governed actions through `/bridge/actions/*` +- set `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` when `tools/bifrost-bridge.mjs` should round-trip every action through the app ledger before mutating GitHub, Discord, or Reddit +- the same bridge URL/token pair lets `tools/dispatch-agent-requests.mjs` record `/dispatch/runs/*` receipts for launched Codex work, so Bifrost keeps a live run trail even when a turn fails +- GitHub bridge mutations now fail closed without those values; `--allow-ungated-github true` is reserved for explicit operator recovery - set `BIFROST_REDDIT_CLIENT_ID`, `BIFROST_REDDIT_REFRESH_TOKEN`, and optionally `BIFROST_REDDIT_CLIENT_SECRET` before posting Reddit organizing threads - set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production - default connection string points at local PostgreSQL diff --git a/docs/agent-transport.md b/docs/agent-transport.md index f0e5055..324818d 100644 --- a/docs/agent-transport.md +++ b/docs/agent-transport.md @@ -125,6 +125,7 @@ node .\tools\agent-transport.mjs apply-snapshot --in E:\tmp\bifrost-agent-transp `enqueue` also mirrors the queued request into `#bifrost` by default. Use `--mirror-content-file` when a Persona has a better human-facing line than the fallback receipt; use `--mirror-dry-run true` only for smoke tests. Dispatch receipts should use Bifrost's own public persona in the Bifrost governance channel. `tools/dispatch-agent-requests.mjs` reads `BIFROST_DISCORD_CHANNEL_ID` or `DISCORD_BIFROST_CHANNEL_ID` for the receipt target, uses persona name `Bifrost`, and defaults the persona avatar to the public `src/Bifrost.Web/wwwroot/img/bifrost-profile.png` raw GitHub URL unless `BIFROST_DISCORD_PERSONA_AVATAR_URL` or `DISCORD_PERSONA_AVATAR_URL_BIFROST` overrides it. Receipt text must lead with the concrete repo and work title, not a generic "recent consensus" summary, and must not expose request ids, workspace paths, log paths, or other debugging debris in Discord. +The dispatcher now tags those Discord receipts with the Bifrost request id as bridge provenance, and dispatched Codex turns inherit default bridge provenance environment variables so any explicit `tools/bifrost-bridge.mjs` crossing inside the turn can land on the same request trail without hand-carried flags. When `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` are configured, the dispatcher also records `/dispatch/runs/*` receipts in the hosted app so Bifrost keeps a first-class run ledger for launch, completion, cancellation, and failure. The `snapshot` command writes a MessagePack-encoded `cultnet.snapshot_response_raw.v0` message. This keeps CultCache payload bytes intact for peers that share the document binding. diff --git a/docs/bridge.md b/docs/bridge.md index ebfb597..094bd45 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -45,6 +45,45 @@ It intentionally uses boring local credentials while Heimdall's managed GitHub c The CLI is not the final permission system. It is the working bridge actuator Bifrost owns now, so VoidBot and repo Personas can stop carrying this machinery themselves. +## Bridge Action Ledger + +The hosted app now exposes a first-class bridge action ledger for governed transport requests: + +- `POST /bridge/actions/request` +- `GET /bridge/actions/{id}` +- `POST /bridge/actions/{id}/start` +- `POST /bridge/actions/{id}/complete` +- `POST /bridge/actions/{id}/fail` + +The owner is Bifrost, not the local script. The script or agent may execute the crossing, but Bifrost records the request, policy decision, lifecycle state, and receipt. + +The hosted app also exposes a dispatch-run ledger for launched agent work: + +- `POST /dispatch/runs/start` +- `POST /dispatch/runs/{id}/complete` +- `POST /dispatch/runs/{id}/fail` + +This is the companion trail to bridge actions. A bridge action says "this crossing into GitHub/Discord/Reddit was requested and receipted." A dispatch run says "this specific Codex worker actually launched, ran, and ended this way." One guards governed mutation. The other proves agent activity. + +Current gate behavior: + +- active Bifrost members may request bridge actions from an authenticated app session +- the transitional local bridge actuator may call the same endpoints with `X-Bifrost-Bridge-Token` +- agent, Persona, and service actions must cite provenance: `authorityReference`, `sourceKind` plus `sourceId`, `workItemId`, or `motionId` +- completed actions must report a receipt URL, external receipt id, or serialized receipt payload +- GitHub mutation commands fail closed unless Bifrost authorization is configured; `--allow-ungated-github true` is the explicit operator-recovery hatch + +Current CLI wiring: + +- set `BIFROST_BRIDGE_BASE_URL` to the Bifrost app base URL +- set `BIFROST_BRIDGE_TOKEN` to the configured local bridge token +- GitHub commands now require both values by default, even for dry-run, so the normal path cannot silently bypass the gate +- pass `--source-kind`, `--source-id`, `--authority-ref`, `--work-item-id`, or `--motion-id` so agent actions satisfy policy +- the dispatcher now preloads `BIFROST_BRIDGE_SOURCE_KIND`, `BIFROST_BRIDGE_SOURCE_ID`, and `BIFROST_BRIDGE_AUTHORITY_REF` into Codex turns launched from Bifrost update requests, so normal bridge use inside a dispatched turn carries request provenance by default +- the dispatcher also uses that same bridge configuration to post dispatch-run start/complete/fail receipts, including the worker pid, thread/turn ids when available, and result/log paths for the local operator trail + +If the app bridge configuration is absent, `tools/bifrost-bridge.mjs` keeps working as a local actuator. When configured, it asks Bifrost for authorization before acting and reports success or failure back to the same bridge action row. + ### GitHub Draft PR ```powershell diff --git a/src/Bifrost.Web/Configuration/BridgeOptions.cs b/src/Bifrost.Web/Configuration/BridgeOptions.cs new file mode 100644 index 0000000..2d90eae --- /dev/null +++ b/src/Bifrost.Web/Configuration/BridgeOptions.cs @@ -0,0 +1,10 @@ +namespace Bifrost.Web.Configuration; + +public sealed class BridgeOptions +{ + public const string SectionName = "Bridge"; + + public string LocalBridgeToken { get; init; } = string.Empty; + + public bool HasLocalBridgeToken => !string.IsNullOrWhiteSpace(LocalBridgeToken); +} diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index d89bc7e..b4e0588 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -53,6 +53,10 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet RevenueShareLines => Set(); + public DbSet BridgeActions => Set(); + + public DbSet AgentDispatchRuns => Set(); + public DbSet AuditEvents => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -395,6 +399,61 @@ public sealed class BifrostDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.SetNull); }); + modelBuilder.Entity(entity => + { + entity.Property(x => x.ActorKind).HasConversion(); + entity.Property(x => x.TargetSurface).HasConversion(); + entity.Property(x => x.ActionKind).HasConversion(); + entity.Property(x => x.Status).HasConversion(); + entity.Property(x => x.ActorName).HasMaxLength(160); + entity.Property(x => x.TargetRepositoryFullName).HasMaxLength(240); + entity.Property(x => x.TargetLocator).HasMaxLength(500); + entity.Property(x => x.SourceKind).HasMaxLength(120); + entity.Property(x => x.SourceId).HasMaxLength(240); + entity.Property(x => x.AuthorityReference).HasMaxLength(240); + entity.Property(x => x.PolicyDecision).HasMaxLength(500); + entity.Property(x => x.Title).HasMaxLength(240); + entity.Property(x => x.ReceiptUrl).HasMaxLength(500); + entity.Property(x => x.ExternalReceiptId).HasMaxLength(240); + entity.HasIndex(x => x.Status); + entity.HasIndex(x => new { x.TargetSurface, x.ActionKind, x.Status }); + entity.HasIndex(x => new { x.ActorKind, x.ActorName }); + entity.HasIndex(x => new { x.SourceKind, x.SourceId }); + entity.HasOne(x => x.ActorUserAccount) + .WithMany(x => x.BridgeActions) + .HasForeignKey(x => x.ActorUserAccountId) + .OnDelete(DeleteBehavior.SetNull); + entity.HasOne(x => x.WorkItem) + .WithMany() + .HasForeignKey(x => x.WorkItemId) + .OnDelete(DeleteBehavior.SetNull); + entity.HasOne(x => x.Motion) + .WithMany() + .HasForeignKey(x => x.MotionId) + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity(entity => + { + entity.Property(x => x.RequestId).HasMaxLength(240); + entity.Property(x => x.TargetRepoName).HasMaxLength(120); + entity.Property(x => x.TargetRepositoryFullName).HasMaxLength(240); + entity.Property(x => x.TargetAgentIdentity).HasMaxLength(160); + entity.Property(x => x.LaunchMode).HasMaxLength(40); + entity.Property(x => x.Status).HasConversion(); + entity.Property(x => x.ThreadId).HasMaxLength(120); + entity.Property(x => x.TurnId).HasMaxLength(120); + entity.Property(x => x.LogPath).HasMaxLength(500); + entity.Property(x => x.ResultPath).HasMaxLength(500); + entity.HasIndex(x => x.RequestId); + entity.HasIndex(x => x.Status); + entity.HasIndex(x => new { x.TargetRepoName, x.TargetAgentIdentity, x.Status }); + entity.HasOne(x => x.StartedByUserAccount) + .WithMany(x => x.AgentDispatchRuns) + .HasForeignKey(x => x.StartedByUserAccountId) + .OnDelete(DeleteBehavior.SetNull); + }); + modelBuilder.Entity(entity => { entity.Property(x => x.EntityType).HasMaxLength(120); diff --git a/src/Bifrost.Web/Data/Migrations/20260617010537_AddBridgeActionLedger.Designer.cs b/src/Bifrost.Web/Data/Migrations/20260617010537_AddBridgeActionLedger.Designer.cs new file mode 100644 index 0000000..b50e2d6 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20260617010537_AddBridgeActionLedger.Designer.cs @@ -0,0 +1,1767 @@ +// +using System; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + [DbContext(typeof(BifrostDbContext))] + [Migration("20260617010537_AddBridgeActionLedger")] + partial class AddBridgeActionLedger + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("Assignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("AuthorityReference") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalReceiptId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("FailureReason") + .IsRequired() + .HasColumnType("text"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReceiptUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetLocator") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TargetSurface") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("MotionId"); + + b.HasIndex("Status"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("ActorKind", "ActorName"); + + b.HasIndex("SourceKind", "SourceId"); + + b.HasIndex("TargetSurface", "ActionKind", "Status"); + + b.ToTable("BridgeActions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.DecayRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RanAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Rate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.HasKey("Id"); + + b.ToTable("DecayRuns"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IssueNumber") + .HasColumnType("integer"); + + b.Property("IssueUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId") + .IsUnique(); + + b.HasIndex("RepositoryFullName", "IssueNumber") + .IsUnique(); + + b.ToTable("GitHubIssueLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsMerged") + .HasColumnType("boolean"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PullRequestNumber") + .HasColumnType("integer"); + + b.Property("PullRequestUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ReviewDecision") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("RepositoryFullName", "PullRequestNumber") + .IsUnique(); + + b.ToTable("GitHubPullRequestLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("EffectiveAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryType") + .IsRequired() + .HasColumnType("text"); + + b.Property("NominalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Points") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("LedgerEntries"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Availability") + .IsRequired() + .HasColumnType("text"); + + b.Property("Headline") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("PortfolioUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Skills") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("MemberProfiles"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CanManageLedger") + .HasColumnType("boolean"); + + b.Property("CanManageProjects") + .HasColumnType("boolean"); + + b.Property("CanModerateMotions") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPlatformAdmin") + .HasColumnType("boolean"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("Memberships"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AcceptedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedByUserAccountId"); + + b.HasIndex("IssuedByUserAccountId"); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique(); + + b.ToTable("MembershipInvitations"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalThreshold") + .HasPrecision(6, 3) + .HasColumnType("numeric(6,3)"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClosesAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("OpensAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ResolutionNote") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.ToTable("Motions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("ExternalSupportId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("IsCurrentRecurringSupport") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProviderEventId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProviderPayerId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProviderSubscriptionId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Provider", "ProviderEventId") + .IsUnique() + .HasFilter("\"ProviderEventId\" <> ''"); + + b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport"); + + b.ToTable("PatronSupportEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.ToTable("PayoutProposalBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDecaying") + .HasColumnType("boolean"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("PointTransactions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GitHubRepository") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("OwnerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GitHubRepository"); + + b.HasIndex("OwnerUserAccountId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("RevenueEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("RevenueEventId") + .HasColumnType("uuid"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("RevenueEventId"); + + b.ToTable("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Basis") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RevenueShareBatchId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RevenueShareBatchId"); + + b.HasIndex("UserAccountId"); + + b.ToTable("RevenueShareLines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("MembershipId", "Role") + .IsUnique(); + + b.ToTable("RoleAssignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapturedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveFromUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("MembershipId", "Kind", "IsCurrent"); + + b.ToTable("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("GitHubUserId") + .HasColumnType("bigint"); + + b.Property("HeimdallAccountId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("GitHubUserId") + .IsUnique() + .HasFilter("\"GitHubUserId\" IS NOT NULL"); + + b.HasIndex("HeimdallAccountId") + .IsUnique() + .HasFilter("\"HeimdallAccountId\" <> ''"); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique() + .HasFilter("\"NormalizedGitHubLogin\" <> ''"); + + b.ToTable("UserAccounts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId", "UserAccountId") + .IsUnique(); + + b.ToTable("VolunteerClaims"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Choice") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("MotionId", "UserAccountId") + .IsUnique(); + + b.ToTable("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedToUserAccountId") + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContributionPoints") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EstimatedHours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ExternalSourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RequestedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("SkillLevel") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RequestedByUserAccountId"); + + b.ToTable("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Hours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkLogs"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReviewerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewerUserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkReviews"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Assignments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("Assignments") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("AuditEvents") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("BridgeActions") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany() + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany() + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + + b.Navigation("Motion"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithOne("GitHubIssueLink") + .HasForeignKey("Bifrost.Web.Domain.GitHubIssueLink", "WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("GitHubPullRequestLinks") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("LedgerEntries") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("LedgerEntries") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("LedgerEntries") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("MemberProfile") + .HasForeignKey("Bifrost.Web.Domain.MemberProfile", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("Membership") + .HasForeignKey("Bifrost.Web.Domain.Membership", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AcceptedByUserAccount") + .WithMany() + .HasForeignKey("AcceptedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "IssuedByUserAccount") + .WithMany() + .HasForeignKey("IssuedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AcceptedByUserAccount"); + + b.Navigation("IssuedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany("CreatedMotions") + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("Motions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PatronSupportEvents") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("PointTransactions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PointTransactions") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("PointTransactions") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "OwnerUserAccount") + .WithMany("OwnedProjects") + .HasForeignKey("OwnerUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OwnerUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueEvents") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.RevenueEvent", "RevenueEvent") + .WithMany("RevenueShareBatches") + .HasForeignKey("RevenueEventId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("RevenueEvent"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueShareLines") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.RevenueShareBatch", "RevenueShareBatch") + .WithMany("Lines") + .HasForeignKey("RevenueShareBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("RevenueShareBatch"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("RoleAssignments") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("TierSnapshots") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("VolunteerClaims") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("VolunteerClaims") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany("Votes") + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Votes") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Motion"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedToUserAccount") + .WithMany("AssignedWorkItems") + .HasForeignKey("AssignedToUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("WorkItems") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "RequestedByUserAccount") + .WithMany("RequestedWorkItems") + .HasForeignKey("RequestedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AssignedToUserAccount"); + + b.Navigation("Project"); + + b.Navigation("RequestedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewedByUserAccount") + .WithMany() + .HasForeignKey("ReviewedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("WorkLogs") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkLogs") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewerUserAccount") + .WithMany("WorkReviews") + .HasForeignKey("ReviewerUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkReviews") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewerUserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Navigation("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Navigation("LedgerEntries"); + + b.Navigation("Motions"); + + b.Navigation("PointTransactions"); + + b.Navigation("RevenueEvents"); + + b.Navigation("RevenueShareLines"); + + b.Navigation("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Navigation("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Navigation("AssignedWorkItems"); + + b.Navigation("Assignments"); + + b.Navigation("AuditEvents"); + + b.Navigation("BridgeActions"); + + b.Navigation("CreatedMotions"); + + b.Navigation("LedgerEntries"); + + b.Navigation("MemberProfile"); + + b.Navigation("Membership"); + + b.Navigation("OwnedProjects"); + + b.Navigation("PatronSupportEvents"); + + b.Navigation("PointTransactions"); + + b.Navigation("RequestedWorkItems"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("Votes"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Navigation("Assignments"); + + b.Navigation("GitHubIssueLink"); + + b.Navigation("GitHubPullRequestLinks"); + + b.Navigation("LedgerEntries"); + + b.Navigation("PointTransactions"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/20260617010537_AddBridgeActionLedger.cs b/src/Bifrost.Web/Data/Migrations/20260617010537_AddBridgeActionLedger.cs new file mode 100644 index 0000000..f3296ae --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20260617010537_AddBridgeActionLedger.cs @@ -0,0 +1,110 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + /// + public partial class AddBridgeActionLedger : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BridgeActions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ActorKind = table.Column(type: "text", nullable: false), + ActorUserAccountId = table.Column(type: "uuid", nullable: true), + ActorName = table.Column(type: "character varying(160)", maxLength: 160, nullable: false), + TargetSurface = table.Column(type: "text", nullable: false), + ActionKind = table.Column(type: "text", nullable: false), + Status = table.Column(type: "text", nullable: false), + WorkItemId = table.Column(type: "uuid", nullable: true), + MotionId = table.Column(type: "uuid", nullable: true), + TargetRepositoryFullName = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + TargetLocator = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + SourceKind = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + SourceId = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + AuthorityReference = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + PolicyDecision = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + Title = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + Summary = table.Column(type: "text", nullable: false), + ReceiptUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + ExternalReceiptId = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + ReceiptPayload = table.Column(type: "text", nullable: false), + FailureReason = table.Column(type: "text", nullable: false), + RequestedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + StartedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + CompletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BridgeActions", x => x.Id); + table.ForeignKey( + name: "FK_BridgeActions_Motions_MotionId", + column: x => x.MotionId, + principalTable: "Motions", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_BridgeActions_UserAccounts_ActorUserAccountId", + column: x => x.ActorUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_BridgeActions_WorkItems_WorkItemId", + column: x => x.WorkItemId, + principalTable: "WorkItems", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_BridgeActions_ActorKind_ActorName", + table: "BridgeActions", + columns: new[] { "ActorKind", "ActorName" }); + + migrationBuilder.CreateIndex( + name: "IX_BridgeActions_ActorUserAccountId", + table: "BridgeActions", + column: "ActorUserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_BridgeActions_MotionId", + table: "BridgeActions", + column: "MotionId"); + + migrationBuilder.CreateIndex( + name: "IX_BridgeActions_SourceKind_SourceId", + table: "BridgeActions", + columns: new[] { "SourceKind", "SourceId" }); + + migrationBuilder.CreateIndex( + name: "IX_BridgeActions_Status", + table: "BridgeActions", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_BridgeActions_TargetSurface_ActionKind_Status", + table: "BridgeActions", + columns: new[] { "TargetSurface", "ActionKind", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_BridgeActions_WorkItemId", + table: "BridgeActions", + column: "WorkItemId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BridgeActions"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/20260617022305_AddAgentDispatchRuns.Designer.cs b/src/Bifrost.Web/Data/Migrations/20260617022305_AddAgentDispatchRuns.Designer.cs new file mode 100644 index 0000000..f290f5b --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20260617022305_AddAgentDispatchRuns.Designer.cs @@ -0,0 +1,1870 @@ +// +using System; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + [DbContext(typeof(BifrostDbContext))] + [Migration("20260617022305_AddAgentDispatchRuns")] + partial class AddAgentDispatchRuns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .IsRequired() + .HasColumnType("text"); + + b.Property("LaunchMode") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("LogPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ResultPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("TargetRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ThreadId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TurnId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkerProcessId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RequestId"); + + b.HasIndex("StartedByUserAccountId"); + + b.HasIndex("Status"); + + b.HasIndex("TargetRepoName", "TargetAgentIdentity", "Status"); + + b.ToTable("AgentDispatchRuns"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("Assignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("AuthorityReference") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalReceiptId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("FailureReason") + .IsRequired() + .HasColumnType("text"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReceiptUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetLocator") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TargetSurface") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("MotionId"); + + b.HasIndex("Status"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("ActorKind", "ActorName"); + + b.HasIndex("SourceKind", "SourceId"); + + b.HasIndex("TargetSurface", "ActionKind", "Status"); + + b.ToTable("BridgeActions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.DecayRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RanAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Rate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.HasKey("Id"); + + b.ToTable("DecayRuns"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IssueNumber") + .HasColumnType("integer"); + + b.Property("IssueUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId") + .IsUnique(); + + b.HasIndex("RepositoryFullName", "IssueNumber") + .IsUnique(); + + b.ToTable("GitHubIssueLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsMerged") + .HasColumnType("boolean"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PullRequestNumber") + .HasColumnType("integer"); + + b.Property("PullRequestUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ReviewDecision") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("RepositoryFullName", "PullRequestNumber") + .IsUnique(); + + b.ToTable("GitHubPullRequestLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("EffectiveAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryType") + .IsRequired() + .HasColumnType("text"); + + b.Property("NominalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Points") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("LedgerEntries"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Availability") + .IsRequired() + .HasColumnType("text"); + + b.Property("Headline") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("PortfolioUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Skills") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("MemberProfiles"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CanManageLedger") + .HasColumnType("boolean"); + + b.Property("CanManageProjects") + .HasColumnType("boolean"); + + b.Property("CanModerateMotions") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPlatformAdmin") + .HasColumnType("boolean"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("Memberships"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AcceptedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedByUserAccountId"); + + b.HasIndex("IssuedByUserAccountId"); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique(); + + b.ToTable("MembershipInvitations"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalThreshold") + .HasPrecision(6, 3) + .HasColumnType("numeric(6,3)"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClosesAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("OpensAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ResolutionNote") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.ToTable("Motions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("ExternalSupportId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("IsCurrentRecurringSupport") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProviderEventId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProviderPayerId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProviderSubscriptionId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Provider", "ProviderEventId") + .IsUnique() + .HasFilter("\"ProviderEventId\" <> ''"); + + b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport"); + + b.ToTable("PatronSupportEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.ToTable("PayoutProposalBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDecaying") + .HasColumnType("boolean"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("PointTransactions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GitHubRepository") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("OwnerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GitHubRepository"); + + b.HasIndex("OwnerUserAccountId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("RevenueEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("RevenueEventId") + .HasColumnType("uuid"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("RevenueEventId"); + + b.ToTable("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Basis") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RevenueShareBatchId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RevenueShareBatchId"); + + b.HasIndex("UserAccountId"); + + b.ToTable("RevenueShareLines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("MembershipId", "Role") + .IsUnique(); + + b.ToTable("RoleAssignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapturedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveFromUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("MembershipId", "Kind", "IsCurrent"); + + b.ToTable("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("GitHubUserId") + .HasColumnType("bigint"); + + b.Property("HeimdallAccountId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("GitHubUserId") + .IsUnique() + .HasFilter("\"GitHubUserId\" IS NOT NULL"); + + b.HasIndex("HeimdallAccountId") + .IsUnique() + .HasFilter("\"HeimdallAccountId\" <> ''"); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique() + .HasFilter("\"NormalizedGitHubLogin\" <> ''"); + + b.ToTable("UserAccounts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId", "UserAccountId") + .IsUnique(); + + b.ToTable("VolunteerClaims"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Choice") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("MotionId", "UserAccountId") + .IsUnique(); + + b.ToTable("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedToUserAccountId") + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContributionPoints") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EstimatedHours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ExternalSourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RequestedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("SkillLevel") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RequestedByUserAccountId"); + + b.ToTable("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Hours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkLogs"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReviewerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewerUserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkReviews"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "StartedByUserAccount") + .WithMany("AgentDispatchRuns") + .HasForeignKey("StartedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("StartedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Assignments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("Assignments") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("AuditEvents") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("BridgeActions") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany() + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany() + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + + b.Navigation("Motion"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithOne("GitHubIssueLink") + .HasForeignKey("Bifrost.Web.Domain.GitHubIssueLink", "WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("GitHubPullRequestLinks") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("LedgerEntries") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("LedgerEntries") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("LedgerEntries") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("MemberProfile") + .HasForeignKey("Bifrost.Web.Domain.MemberProfile", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("Membership") + .HasForeignKey("Bifrost.Web.Domain.Membership", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AcceptedByUserAccount") + .WithMany() + .HasForeignKey("AcceptedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "IssuedByUserAccount") + .WithMany() + .HasForeignKey("IssuedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AcceptedByUserAccount"); + + b.Navigation("IssuedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany("CreatedMotions") + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("Motions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PatronSupportEvents") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("PointTransactions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PointTransactions") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("PointTransactions") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "OwnerUserAccount") + .WithMany("OwnedProjects") + .HasForeignKey("OwnerUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OwnerUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueEvents") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.RevenueEvent", "RevenueEvent") + .WithMany("RevenueShareBatches") + .HasForeignKey("RevenueEventId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("RevenueEvent"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueShareLines") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.RevenueShareBatch", "RevenueShareBatch") + .WithMany("Lines") + .HasForeignKey("RevenueShareBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("RevenueShareBatch"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("RoleAssignments") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("TierSnapshots") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("VolunteerClaims") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("VolunteerClaims") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany("Votes") + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Votes") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Motion"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedToUserAccount") + .WithMany("AssignedWorkItems") + .HasForeignKey("AssignedToUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("WorkItems") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "RequestedByUserAccount") + .WithMany("RequestedWorkItems") + .HasForeignKey("RequestedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AssignedToUserAccount"); + + b.Navigation("Project"); + + b.Navigation("RequestedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewedByUserAccount") + .WithMany() + .HasForeignKey("ReviewedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("WorkLogs") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkLogs") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewerUserAccount") + .WithMany("WorkReviews") + .HasForeignKey("ReviewerUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkReviews") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewerUserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Navigation("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Navigation("LedgerEntries"); + + b.Navigation("Motions"); + + b.Navigation("PointTransactions"); + + b.Navigation("RevenueEvents"); + + b.Navigation("RevenueShareLines"); + + b.Navigation("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Navigation("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Navigation("AgentDispatchRuns"); + + b.Navigation("AssignedWorkItems"); + + b.Navigation("Assignments"); + + b.Navigation("AuditEvents"); + + b.Navigation("BridgeActions"); + + b.Navigation("CreatedMotions"); + + b.Navigation("LedgerEntries"); + + b.Navigation("MemberProfile"); + + b.Navigation("Membership"); + + b.Navigation("OwnedProjects"); + + b.Navigation("PatronSupportEvents"); + + b.Navigation("PointTransactions"); + + b.Navigation("RequestedWorkItems"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("Votes"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Navigation("Assignments"); + + b.Navigation("GitHubIssueLink"); + + b.Navigation("GitHubPullRequestLinks"); + + b.Navigation("LedgerEntries"); + + b.Navigation("PointTransactions"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/20260617022305_AddAgentDispatchRuns.cs b/src/Bifrost.Web/Data/Migrations/20260617022305_AddAgentDispatchRuns.cs new file mode 100644 index 0000000..295bd28 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20260617022305_AddAgentDispatchRuns.cs @@ -0,0 +1,76 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + /// + public partial class AddAgentDispatchRuns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AgentDispatchRuns", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RequestId = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + TargetRepoName = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + TargetRepositoryFullName = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + TargetAgentIdentity = table.Column(type: "character varying(160)", maxLength: 160, nullable: false), + LaunchMode = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + Status = table.Column(type: "text", nullable: false), + StartedByUserAccountId = table.Column(type: "uuid", nullable: true), + WorkerProcessId = table.Column(type: "integer", nullable: true), + ThreadId = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + TurnId = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + LogPath = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + ResultPath = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + Note = table.Column(type: "text", nullable: false), + Error = table.Column(type: "text", nullable: false), + StartedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + CompletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AgentDispatchRuns", x => x.Id); + table.ForeignKey( + name: "FK_AgentDispatchRuns_UserAccounts_StartedByUserAccountId", + column: x => x.StartedByUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_AgentDispatchRuns_RequestId", + table: "AgentDispatchRuns", + column: "RequestId"); + + migrationBuilder.CreateIndex( + name: "IX_AgentDispatchRuns_StartedByUserAccountId", + table: "AgentDispatchRuns", + column: "StartedByUserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_AgentDispatchRuns_Status", + table: "AgentDispatchRuns", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_AgentDispatchRuns_TargetRepoName_TargetAgentIdentity_Status", + table: "AgentDispatchRuns", + columns: new[] { "TargetRepoName", "TargetAgentIdentity", "Status" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AgentDispatchRuns"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index aef12ad..27bc77f 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -22,6 +22,97 @@ namespace Bifrost.Web.Data.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .IsRequired() + .HasColumnType("text"); + + b.Property("LaunchMode") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("LogPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ResultPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("TargetRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ThreadId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TurnId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkerProcessId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RequestId"); + + b.HasIndex("StartedByUserAccountId"); + + b.HasIndex("Status"); + + b.HasIndex("TargetRepoName", "TargetAgentIdentity", "Status"); + + b.ToTable("AgentDispatchRuns"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => { b.Property("Id") @@ -93,6 +184,130 @@ namespace Bifrost.Web.Data.Migrations b.ToTable("AuditEvents"); }); + modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("AuthorityReference") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalReceiptId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("FailureReason") + .IsRequired() + .HasColumnType("text"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReceiptUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetLocator") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TargetSurface") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("MotionId"); + + b.HasIndex("Status"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("ActorKind", "ActorName"); + + b.HasIndex("SourceKind", "SourceId"); + + b.HasIndex("TargetSurface", "ActionKind", "Status"); + + b.ToTable("BridgeActions"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.DecayRun", b => { b.Property("Id") @@ -497,9 +712,6 @@ namespace Bifrost.Web.Data.Migrations .IsRequired() .HasColumnType("text"); - b.Property("RecordedAtUtc") - .HasColumnType("timestamp with time zone"); - b.Property("Provider") .IsRequired() .HasColumnType("text"); @@ -519,6 +731,9 @@ namespace Bifrost.Web.Data.Migrations .HasMaxLength(240) .HasColumnType("character varying(240)"); + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + b.Property("SupportedAtUtc") .HasColumnType("timestamp with time zone"); @@ -527,14 +742,12 @@ namespace Bifrost.Web.Data.Migrations b.HasKey("Id"); - b.HasIndex("UserAccountId"); - - b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport"); - b.HasIndex("Provider", "ProviderEventId") .IsUnique() .HasFilter("\"ProviderEventId\" <> ''"); + b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport"); + b.ToTable("PatronSupportEvents"); }); @@ -1125,6 +1338,16 @@ namespace Bifrost.Web.Data.Migrations b.ToTable("WorkReviews"); }); + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "StartedByUserAccount") + .WithMany("AgentDispatchRuns") + .HasForeignKey("StartedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("StartedByUserAccount"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => { b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") @@ -1162,6 +1385,30 @@ namespace Bifrost.Web.Data.Migrations b.Navigation("ActorUserAccount"); }); + modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("BridgeActions") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany() + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany() + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + + b.Navigation("Motion"); + + b.Navigation("WorkItem"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => { b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") @@ -1561,12 +1808,16 @@ namespace Bifrost.Web.Data.Migrations modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => { + b.Navigation("AgentDispatchRuns"); + b.Navigation("AssignedWorkItems"); b.Navigation("Assignments"); b.Navigation("AuditEvents"); + b.Navigation("BridgeActions"); + b.Navigation("CreatedMotions"); b.Navigation("LedgerEntries"); diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index 331852e..e0acca8 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -174,6 +174,55 @@ public enum ExternalPatronProvider PayPal } +public enum BridgeActorKind +{ + Member, + Agent, + Persona, + Service +} + +public enum BridgeTargetSurface +{ + GitHub, + Discord, + Reddit, + CultNet, + CultCache, + Other +} + +public enum BridgeActionKind +{ + GitHubDraftPullRequest, + GitHubPullRequestComment, + GitHubIssueComment, + DiscordPost, + DiscordDirectMessage, + RedditPost, + AgentUpdateRequest, + Other +} + +public enum BridgeActionStatus +{ + Requested, + Authorized, + Denied, + InProgress, + Completed, + Failed, + Cancelled +} + +public enum AgentDispatchRunStatus +{ + Started, + Completed, + Cancelled, + Failed +} + public sealed class UserAccount { public Guid Id { get; set; } = Guid.NewGuid(); @@ -222,6 +271,10 @@ public sealed class UserAccount public ICollection PatronSupportEvents { get; set; } = []; + public ICollection BridgeActions { get; set; } = []; + + public ICollection AgentDispatchRuns { get; set; } = []; + public ICollection AuditEvents { get; set; } = []; } @@ -822,6 +875,106 @@ public sealed class RevenueShareLine public Project? Project { get; set; } } +public sealed class BridgeAction +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public BridgeActorKind ActorKind { get; set; } = BridgeActorKind.Agent; + + public Guid? ActorUserAccountId { get; set; } + + public string ActorName { get; set; } = string.Empty; + + public BridgeTargetSurface TargetSurface { get; set; } = BridgeTargetSurface.GitHub; + + public BridgeActionKind ActionKind { get; set; } = BridgeActionKind.GitHubDraftPullRequest; + + public BridgeActionStatus Status { get; set; } = BridgeActionStatus.Requested; + + public Guid? WorkItemId { get; set; } + + public Guid? MotionId { get; set; } + + public string TargetRepositoryFullName { get; set; } = string.Empty; + + public string TargetLocator { get; set; } = string.Empty; + + public string SourceKind { get; set; } = string.Empty; + + public string SourceId { get; set; } = string.Empty; + + public string AuthorityReference { get; set; } = string.Empty; + + public string PolicyDecision { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public string Summary { get; set; } = string.Empty; + + public string ReceiptUrl { get; set; } = string.Empty; + + public string ExternalReceiptId { get; set; } = string.Empty; + + public string ReceiptPayload { get; set; } = string.Empty; + + public string FailureReason { get; set; } = string.Empty; + + public DateTimeOffset RequestedAtUtc { get; set; } + + public DateTimeOffset UpdatedAtUtc { get; set; } + + public DateTimeOffset? StartedAtUtc { get; set; } + + public DateTimeOffset? CompletedAtUtc { get; set; } + + public UserAccount? ActorUserAccount { get; set; } + + public WorkItem? WorkItem { get; set; } + + public Motion? Motion { get; set; } +} + +public sealed class AgentDispatchRun +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public string RequestId { get; set; } = string.Empty; + + public string TargetRepoName { get; set; } = string.Empty; + + public string TargetRepositoryFullName { get; set; } = string.Empty; + + public string TargetAgentIdentity { get; set; } = string.Empty; + + public string LaunchMode { get; set; } = string.Empty; + + public AgentDispatchRunStatus Status { get; set; } = AgentDispatchRunStatus.Started; + + public Guid? StartedByUserAccountId { get; set; } + + public int? WorkerProcessId { get; set; } + + public string ThreadId { get; set; } = string.Empty; + + public string TurnId { get; set; } = string.Empty; + + public string LogPath { get; set; } = string.Empty; + + public string ResultPath { get; set; } = string.Empty; + + public string Note { get; set; } = string.Empty; + + public string Error { get; set; } = string.Empty; + + public DateTimeOffset StartedAtUtc { get; set; } + + public DateTimeOffset UpdatedAtUtc { get; set; } + + public DateTimeOffset? CompletedAtUtc { get; set; } + + public UserAccount? StartedByUserAccount { get; set; } +} + public sealed class AuditEvent { public Guid Id { get; set; } = Guid.NewGuid(); diff --git a/src/Bifrost.Web/Features/Bridge/AgentDispatchRunService.cs b/src/Bifrost.Web/Features/Bridge/AgentDispatchRunService.cs new file mode 100644 index 0000000..8e23c74 --- /dev/null +++ b/src/Bifrost.Web/Features/Bridge/AgentDispatchRunService.cs @@ -0,0 +1,196 @@ +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Features.Bridge; + +public sealed class AgentDispatchRunService( + BifrostDbContext dbContext, + AuditTrailService auditTrailService, + TimeProvider timeProvider) +{ + public async Task StartAsync( + AgentDispatchRunStartRequest request, + Guid? startedByUserAccountId, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var run = new AgentDispatchRun + { + RequestId = NormalizeText(request.RequestId), + TargetRepoName = NormalizeText(request.TargetRepoName), + TargetRepositoryFullName = NormalizeText(request.TargetRepositoryFullName), + TargetAgentIdentity = NormalizeText(request.TargetAgentIdentity), + LaunchMode = NormalizeText(request.LaunchMode), + Status = AgentDispatchRunStatus.Started, + StartedByUserAccountId = startedByUserAccountId, + WorkerProcessId = request.WorkerProcessId, + ThreadId = NormalizeText(request.ThreadId), + TurnId = NormalizeText(request.TurnId), + LogPath = NormalizeText(request.LogPath), + ResultPath = NormalizeText(request.ResultPath), + Note = NormalizeText(request.Note), + StartedAtUtc = now, + UpdatedAtUtc = now + }; + + dbContext.AgentDispatchRuns.Add(run); + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync( + startedByUserAccountId, + nameof(AgentDispatchRun), + run.Id, + "agent-dispatch.started", + $"Dispatch run started for request {run.RequestId}.", + cancellationToken); + + return AgentDispatchRunResult.From(run); + } + + public Task CompleteAsync( + Guid runId, + AgentDispatchRunCompletionRequest request, + Guid? startedByUserAccountId, + CancellationToken cancellationToken) => + UpdateAsync( + runId, + startedByUserAccountId, + "agent-dispatch.completed", + run => + { + var now = timeProvider.GetUtcNow(); + run.Status = request.Status; + run.Note = NormalizeText(request.Note); + run.Error = string.Empty; + run.ThreadId = string.IsNullOrWhiteSpace(request.ThreadId) ? run.ThreadId : NormalizeText(request.ThreadId); + run.TurnId = string.IsNullOrWhiteSpace(request.TurnId) ? run.TurnId : NormalizeText(request.TurnId); + run.ResultPath = string.IsNullOrWhiteSpace(request.ResultPath) ? run.ResultPath : NormalizeText(request.ResultPath); + run.UpdatedAtUtc = now; + run.CompletedAtUtc = now; + return $"Dispatch run {run.RequestId} completed as {run.Status}."; + }, + cancellationToken); + + public Task FailAsync( + Guid runId, + AgentDispatchRunFailureRequest request, + Guid? startedByUserAccountId, + CancellationToken cancellationToken) => + UpdateAsync( + runId, + startedByUserAccountId, + "agent-dispatch.failed", + run => + { + var now = timeProvider.GetUtcNow(); + run.Status = AgentDispatchRunStatus.Failed; + run.Note = NormalizeText(request.Note); + run.Error = NormalizeText(request.Error); + run.ThreadId = string.IsNullOrWhiteSpace(request.ThreadId) ? run.ThreadId : NormalizeText(request.ThreadId); + run.TurnId = string.IsNullOrWhiteSpace(request.TurnId) ? run.TurnId : NormalizeText(request.TurnId); + run.ResultPath = string.IsNullOrWhiteSpace(request.ResultPath) ? run.ResultPath : NormalizeText(request.ResultPath); + run.UpdatedAtUtc = now; + run.CompletedAtUtc = now; + return string.IsNullOrWhiteSpace(run.Error) + ? $"Dispatch run {run.RequestId} failed." + : run.Error; + }, + cancellationToken); + + private async Task UpdateAsync( + Guid runId, + Guid? startedByUserAccountId, + string auditAction, + Func update, + CancellationToken cancellationToken) + { + var run = await dbContext.AgentDispatchRuns + .SingleOrDefaultAsync(x => x.Id == runId, cancellationToken); + + if (run is null) + { + return null; + } + + var detail = update(run); + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync( + startedByUserAccountId, + nameof(AgentDispatchRun), + run.Id, + auditAction, + detail, + cancellationToken); + + return AgentDispatchRunResult.From(run); + } + + private static string NormalizeText(string? value) => value?.Trim() ?? string.Empty; +} + +public sealed record AgentDispatchRunStartRequest( + string RequestId, + string TargetRepoName, + string TargetRepositoryFullName, + string TargetAgentIdentity, + string LaunchMode, + int? WorkerProcessId, + string ThreadId, + string TurnId, + string LogPath, + string ResultPath, + string Note); + +public sealed record AgentDispatchRunCompletionRequest( + AgentDispatchRunStatus Status, + string ThreadId, + string TurnId, + string ResultPath, + string Note); + +public sealed record AgentDispatchRunFailureRequest( + string ThreadId, + string TurnId, + string ResultPath, + string Note, + string Error); + +public sealed record AgentDispatchRunResult( + Guid Id, + string RequestId, + string TargetRepoName, + string TargetRepositoryFullName, + string TargetAgentIdentity, + string LaunchMode, + AgentDispatchRunStatus Status, + int? WorkerProcessId, + string ThreadId, + string TurnId, + string LogPath, + string ResultPath, + string Note, + string Error, + DateTimeOffset StartedAtUtc, + DateTimeOffset UpdatedAtUtc, + DateTimeOffset? CompletedAtUtc) +{ + public static AgentDispatchRunResult From(AgentDispatchRun run) => new( + run.Id, + run.RequestId, + run.TargetRepoName, + run.TargetRepositoryFullName, + run.TargetAgentIdentity, + run.LaunchMode, + run.Status, + run.WorkerProcessId, + run.ThreadId, + run.TurnId, + run.LogPath, + run.ResultPath, + run.Note, + run.Error, + run.StartedAtUtc, + run.UpdatedAtUtc, + run.CompletedAtUtc); +} diff --git a/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs b/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs new file mode 100644 index 0000000..21f9696 --- /dev/null +++ b/src/Bifrost.Web/Features/Bridge/BridgeActionService.cs @@ -0,0 +1,337 @@ +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Features.Bridge; + +public sealed class BridgeActionService( + BifrostDbContext dbContext, + AuditTrailService auditTrailService, + TimeProvider timeProvider) +{ + public async Task RequestAsync( + BridgeActionRequest request, + BridgeCaller caller, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var normalizedRequest = request.Normalize(caller); + var policy = await EvaluatePolicyAsync(normalizedRequest, caller, cancellationToken); + + var action = new BridgeAction + { + ActorKind = normalizedRequest.ActorKind, + ActorUserAccountId = caller.UserAccountId, + ActorName = normalizedRequest.ActorName, + TargetSurface = normalizedRequest.TargetSurface, + ActionKind = normalizedRequest.ActionKind, + Status = policy.Allowed ? BridgeActionStatus.Authorized : BridgeActionStatus.Denied, + WorkItemId = normalizedRequest.WorkItemId, + MotionId = normalizedRequest.MotionId, + TargetRepositoryFullName = NormalizeRepository(normalizedRequest.TargetRepositoryFullName), + TargetLocator = NormalizeText(normalizedRequest.TargetLocator), + SourceKind = NormalizeText(normalizedRequest.SourceKind), + SourceId = NormalizeText(normalizedRequest.SourceId), + AuthorityReference = NormalizeText(normalizedRequest.AuthorityReference), + PolicyDecision = policy.Decision, + Title = NormalizeText(normalizedRequest.Title), + Summary = NormalizeText(normalizedRequest.Summary), + RequestedAtUtc = now, + UpdatedAtUtc = now + }; + + dbContext.BridgeActions.Add(action); + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync( + caller.UserAccountId, + nameof(BridgeAction), + action.Id, + policy.Allowed ? "bridge.authorized" : "bridge.denied", + policy.Decision, + cancellationToken); + + return BridgeActionResult.From(action); + } + + public Task StartAsync( + Guid actionId, + BridgeCaller caller, + CancellationToken cancellationToken) => + UpdateAsync( + actionId, + caller, + "bridge.started", + action => + { + if (action.Status is not BridgeActionStatus.Authorized) + { + throw new BridgeActionException("Only authorized bridge actions can be started."); + } + + var now = timeProvider.GetUtcNow(); + action.Status = BridgeActionStatus.InProgress; + action.StartedAtUtc = now; + action.UpdatedAtUtc = now; + return "Bridge action started."; + }, + cancellationToken); + + public Task CompleteAsync( + Guid actionId, + BridgeActionReceiptRequest request, + BridgeCaller caller, + CancellationToken cancellationToken) => + UpdateAsync( + actionId, + caller, + "bridge.completed", + action => + { + if (action.Status is not (BridgeActionStatus.Authorized or BridgeActionStatus.InProgress)) + { + throw new BridgeActionException("Only authorized or in-progress bridge actions can be completed."); + } + + if (string.IsNullOrWhiteSpace(request.ReceiptUrl) && + string.IsNullOrWhiteSpace(request.ExternalReceiptId) && + string.IsNullOrWhiteSpace(request.ReceiptPayload)) + { + throw new BridgeActionException("A completed bridge action must include a receipt URL, external receipt id, or receipt payload."); + } + + var now = timeProvider.GetUtcNow(); + action.Status = BridgeActionStatus.Completed; + action.ReceiptUrl = NormalizeText(request.ReceiptUrl); + action.ExternalReceiptId = NormalizeText(request.ExternalReceiptId); + action.ReceiptPayload = NormalizeText(request.ReceiptPayload); + action.CompletedAtUtc = now; + action.UpdatedAtUtc = now; + return "Bridge action completed with receipt."; + }, + cancellationToken); + + public Task FailAsync( + Guid actionId, + BridgeActionFailureRequest request, + BridgeCaller caller, + CancellationToken cancellationToken) => + UpdateAsync( + actionId, + caller, + "bridge.failed", + action => + { + var now = timeProvider.GetUtcNow(); + action.Status = BridgeActionStatus.Failed; + action.FailureReason = NormalizeText(request.FailureReason); + action.UpdatedAtUtc = now; + action.CompletedAtUtc = now; + return string.IsNullOrWhiteSpace(action.FailureReason) + ? "Bridge action failed." + : action.FailureReason; + }, + cancellationToken); + + private async Task UpdateAsync( + Guid actionId, + BridgeCaller caller, + string auditAction, + Func update, + CancellationToken cancellationToken) + { + var action = await dbContext.BridgeActions + .SingleOrDefaultAsync(x => x.Id == actionId, cancellationToken); + + if (action is null) + { + return null; + } + + if (!caller.CanOperate(action)) + { + throw new UnauthorizedAccessException("Caller cannot operate this bridge action."); + } + + var detail = update(action); + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync( + caller.UserAccountId, + nameof(BridgeAction), + action.Id, + auditAction, + detail, + cancellationToken); + + return BridgeActionResult.From(action); + } + + private async Task EvaluatePolicyAsync( + BridgeActionRequest request, + BridgeCaller caller, + CancellationToken cancellationToken) + { + if (!caller.IsAllowedTransport) + { + return BridgePolicyDecision.Reject("Bridge actions require an active member session or a configured local bridge token."); + } + + if (request.TargetSurface == BridgeTargetSurface.GitHub && + string.IsNullOrWhiteSpace(request.TargetRepositoryFullName)) + { + return BridgePolicyDecision.Reject("GitHub bridge actions must name a target repository."); + } + + if (request.ActorKind is BridgeActorKind.Agent or BridgeActorKind.Persona or BridgeActorKind.Service) + { + var hasProvenance = + !string.IsNullOrWhiteSpace(request.AuthorityReference) || + (!string.IsNullOrWhiteSpace(request.SourceKind) && !string.IsNullOrWhiteSpace(request.SourceId)) || + request.WorkItemId is not null || + request.MotionId is not null; + + if (!hasProvenance) + { + return BridgePolicyDecision.Reject("Agent and Persona bridge actions must cite an authority reference, source object, work item, or motion."); + } + } + + if (request.WorkItemId is not null) + { + var workItem = await dbContext.WorkItems + .Include(x => x.Project) + .SingleOrDefaultAsync(x => x.Id == request.WorkItemId, cancellationToken); + + if (workItem is null) + { + return BridgePolicyDecision.Reject("Referenced work item does not exist."); + } + + var targetRepository = NormalizeRepository(request.TargetRepositoryFullName); + var projectRepository = NormalizeRepository(workItem.Project.GitHubRepository); + if (!string.IsNullOrWhiteSpace(targetRepository) && + !string.IsNullOrWhiteSpace(projectRepository) && + targetRepository != projectRepository) + { + return BridgePolicyDecision.Reject("GitHub target repository does not match the referenced work item's project."); + } + } + + if (request.MotionId is not null && + !await dbContext.Motions.AnyAsync(x => x.Id == request.MotionId, cancellationToken)) + { + return BridgePolicyDecision.Reject("Referenced motion does not exist."); + } + + return BridgePolicyDecision.Permit( + caller.IsLocalBridge + ? "Authorized through the configured local Bifrost bridge token and Bifrost policy." + : "Authorized for an active Bifrost member through Bifrost policy."); + } + + private static string NormalizeRepository(string value) => NormalizeText(value).ToLowerInvariant(); + + private static string NormalizeText(string? value) => value?.Trim() ?? string.Empty; +} + +public sealed record BridgeCaller( + bool IsActiveMember, + bool IsLocalBridge, + Guid? UserAccountId, + string DisplayName) +{ + public bool IsAllowedTransport => IsActiveMember || IsLocalBridge; + + public bool CanOperate(BridgeAction action) => + IsLocalBridge || + (IsActiveMember && action.ActorUserAccountId == UserAccountId); +} + +public sealed record BridgeActionRequest( + BridgeActorKind ActorKind, + string ActorName, + BridgeTargetSurface TargetSurface, + BridgeActionKind ActionKind, + string TargetRepositoryFullName, + string TargetLocator, + string SourceKind, + string SourceId, + string AuthorityReference, + string Title, + string Summary, + Guid? WorkItemId = null, + Guid? MotionId = null) +{ + public BridgeActionRequest Normalize(BridgeCaller caller) => + this with + { + ActorKind = ActorKind == default && caller.IsActiveMember ? BridgeActorKind.Member : ActorKind, + ActorName = string.IsNullOrWhiteSpace(ActorName) && caller.IsActiveMember + ? caller.DisplayName + : NormalizeText(ActorName), + TargetRepositoryFullName = NormalizeText(TargetRepositoryFullName), + TargetLocator = NormalizeText(TargetLocator), + SourceKind = NormalizeText(SourceKind), + SourceId = NormalizeText(SourceId), + AuthorityReference = NormalizeText(AuthorityReference), + Title = NormalizeText(Title), + Summary = NormalizeText(Summary) + }; + + private static string NormalizeText(string? value) => value?.Trim() ?? string.Empty; +} + +public sealed record BridgeActionReceiptRequest( + string ReceiptUrl, + string ExternalReceiptId, + string ReceiptPayload); + +public sealed record BridgeActionFailureRequest(string FailureReason); + +public sealed record BridgeActionResult( + Guid Id, + BridgeActorKind ActorKind, + string ActorName, + BridgeTargetSurface TargetSurface, + BridgeActionKind ActionKind, + BridgeActionStatus Status, + string TargetRepositoryFullName, + string TargetLocator, + string SourceKind, + string SourceId, + string AuthorityReference, + string PolicyDecision, + string Title, + string ReceiptUrl, + string ExternalReceiptId, + DateTimeOffset RequestedAtUtc, + DateTimeOffset UpdatedAtUtc) +{ + public static BridgeActionResult From(BridgeAction action) => new( + action.Id, + action.ActorKind, + action.ActorName, + action.TargetSurface, + action.ActionKind, + action.Status, + action.TargetRepositoryFullName, + action.TargetLocator, + action.SourceKind, + action.SourceId, + action.AuthorityReference, + action.PolicyDecision, + action.Title, + action.ReceiptUrl, + action.ExternalReceiptId, + action.RequestedAtUtc, + action.UpdatedAtUtc); +} + +public sealed record BridgePolicyDecision(bool Allowed, string Decision) +{ + public static BridgePolicyDecision Permit(string decision) => new(true, decision); + + public static BridgePolicyDecision Reject(string decision) => new(false, decision); +} + +public sealed class BridgeActionException(string message) : InvalidOperationException(message); diff --git a/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs index 851add6..31f1fae 100644 --- a/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs +++ b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs @@ -100,6 +100,40 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro x.OccurredAtUtc)) .ToListAsync(cancellationToken); + var recentBridgeActions = await dbContext.BridgeActions + .AsNoTracking() + .OrderByDescending(x => x.UpdatedAtUtc) + .Take(6) + .Select(x => new DashboardBridgeAction( + x.Id, + x.ActorName, + x.ActorKind.ToString(), + x.TargetSurface.ToString(), + x.ActionKind.ToString(), + x.Status.ToString(), + x.Title, + x.TargetRepositoryFullName, + string.IsNullOrWhiteSpace(x.ReceiptUrl) ? x.ExternalReceiptId : x.ReceiptUrl, + x.UpdatedAtUtc)) + .ToListAsync(cancellationToken); + + var recentDispatchRuns = await dbContext.AgentDispatchRuns + .AsNoTracking() + .OrderByDescending(x => x.UpdatedAtUtc) + .Take(6) + .Select(x => new DashboardDispatchRun( + x.Id, + x.RequestId, + x.TargetRepoName, + x.TargetAgentIdentity, + x.LaunchMode, + x.Status.ToString(), + x.ThreadId, + x.TurnId, + string.IsNullOrWhiteSpace(x.Note) ? x.Error : x.Note, + x.UpdatedAtUtc)) + .ToListAsync(cancellationToken); + var payoutPreviewNominal = await dbContext.LedgerEntries .AsNoTracking() .Where(x => x.Status == LedgerEntryStatus.Approved) @@ -114,13 +148,21 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro OpenMotions: await dbContext.Motions.AsNoTracking().CountAsync(x => x.Status == MotionStatus.Open && x.ClosesAtUtc >= now, cancellationToken), PendingMembershipApprovals: outstandingApprovals, AssignedToCurrentMember: assignedToMe.Count, + ActiveBridgeActions: await dbContext.BridgeActions.AsNoTracking().CountAsync( + x => x.Status == BridgeActionStatus.Authorized || x.Status == BridgeActionStatus.InProgress, + cancellationToken), + ActiveDispatchRuns: await dbContext.AgentDispatchRuns.AsNoTracking().CountAsync( + x => x.Status == AgentDispatchRunStatus.Started, + cancellationToken), ApprovedNominalPayoutValue: payoutPreviewNominal, RecentWorkItems: recentWorkItems, OpenMotionHighlights: openMotions, MyAssignedWork: assignedToMe, MyVolunteeredWork: volunteeredByMe, MySubmittedWork: submittedByMe, - RecentActivity: recentActivity); + RecentActivity: recentActivity, + RecentBridgeActions: recentBridgeActions, + RecentDispatchRuns: recentDispatchRuns); } } @@ -131,13 +173,17 @@ public sealed record DashboardSnapshot( int OpenMotions, int PendingMembershipApprovals, int AssignedToCurrentMember, + int ActiveBridgeActions, + int ActiveDispatchRuns, decimal ApprovedNominalPayoutValue, IReadOnlyList RecentWorkItems, IReadOnlyList OpenMotionHighlights, IReadOnlyList MyAssignedWork, IReadOnlyList MyVolunteeredWork, IReadOnlyList MySubmittedWork, - IReadOnlyList RecentActivity); + IReadOnlyList RecentActivity, + IReadOnlyList RecentBridgeActions, + IReadOnlyList RecentDispatchRuns); public sealed record DashboardWorkItem( Guid WorkItemId, @@ -170,3 +216,27 @@ public sealed record DashboardActivity( string Detail, string ActorName, DateTimeOffset OccurredAtUtc); + +public sealed record DashboardBridgeAction( + Guid BridgeActionId, + string ActorName, + string ActorKind, + string TargetSurface, + string ActionKind, + string Status, + string Title, + string TargetRepositoryFullName, + string ReceiptLabel, + DateTimeOffset UpdatedAtUtc); + +public sealed record DashboardDispatchRun( + Guid AgentDispatchRunId, + string RequestId, + string TargetRepoName, + string TargetAgentIdentity, + string LaunchMode, + string Status, + string ThreadId, + string TurnId, + string Note, + DateTimeOffset UpdatedAtUtc); diff --git a/src/Bifrost.Web/Pages/App/Index.cshtml b/src/Bifrost.Web/Pages/App/Index.cshtml index 2db057c..00a57ed 100644 --- a/src/Bifrost.Web/Pages/App/Index.cshtml +++ b/src/Bifrost.Web/Pages/App/Index.cshtml @@ -191,6 +191,88 @@ } +
+
+
+

Agent runs

+

Recent dispatched Codex runs recorded by Bifrost.

+
+
+ + @if (Model.Snapshot.RecentDispatchRuns.Count == 0) + { +
No dispatched agent runs have been recorded yet.
+ } + else + { +
+ @foreach (var run in Model.Snapshot.RecentDispatchRuns) + { +
+ @run.TargetRepoName@if (!string.IsNullOrWhiteSpace(run.TargetAgentIdentity)) { / @run.TargetAgentIdentity} + @run.LaunchMode dispatch +
+ @run.Status + @run.RequestId +
+ @if (!string.IsNullOrWhiteSpace(run.ThreadId) || !string.IsNullOrWhiteSpace(run.TurnId)) + { +
Thread @run.ThreadId Turn @run.TurnId
+ } + @if (!string.IsNullOrWhiteSpace(run.Note)) + { +
@run.Note
+ } +
@run.UpdatedAtUtc.ToString("dd MMM yyyy HH:mm") UTC
+
+ } +
+ } +
+ + +
+
+
+
+

Bridge receipts

+

Recent agent and service crossings recorded by Bifrost.

+
+
+ + @if (Model.Snapshot.RecentBridgeActions.Count == 0) + { +
No bridge actions have been recorded yet.
+ } + else + { +
+ @foreach (var action in Model.Snapshot.RecentBridgeActions) + { +
+ @action.ActorName + @action.ActionKind on @action.TargetSurface +
+ @action.Status + @if (!string.IsNullOrWhiteSpace(action.TargetRepositoryFullName)) + { + @action.TargetRepositoryFullName + } +
+
@action.Title
+ @if (!string.IsNullOrWhiteSpace(action.ReceiptLabel)) + { +
@action.ReceiptLabel
+ } +
@action.UpdatedAtUtc.ToString("dd MMM yyyy HH:mm") UTC
+
+ } +
+ } +
+
+ +
diff --git a/src/Bifrost.Web/Pages/App/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Index.cshtml.cs index 9139077..80e68a0 100644 --- a/src/Bifrost.Web/Pages/App/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/Index.cshtml.cs @@ -11,7 +11,7 @@ public sealed class IndexModel( public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous; public DashboardSnapshot Snapshot { get; private set; } = - new(0, 0, 0, 0, 0, 0, 0m, [], [], [], [], [], []); + new(0, 0, 0, 0, 0, 0, 0, 0, 0m, [], [], [], [], [], [], [], []); public async Task OnGetAsync(CancellationToken cancellationToken) { diff --git a/src/Bifrost.Web/Pages/App/_SummaryCards.cshtml b/src/Bifrost.Web/Pages/App/_SummaryCards.cshtml index 6e1e4d5..237c207 100644 --- a/src/Bifrost.Web/Pages/App/_SummaryCards.cshtml +++ b/src/Bifrost.Web/Pages/App/_SummaryCards.cshtml @@ -25,6 +25,14 @@ Assigned to me @Model.AssignedToCurrentMember
+
+ Bridge actions live + @Model.ActiveBridgeActions +
+
+ Dispatch runs live + @Model.ActiveDispatchRuns +
Approved nominal payout @Model.ApprovedNominalPayoutValue.ToString("0.##") diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index e0a50d7..151eed3 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -4,6 +4,8 @@ using System.Text.Json; using System.Text.Json.Serialization; using Bifrost.Web.Configuration; using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Bridge; using Bifrost.Web.Features.GitHub; using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Membership; @@ -41,6 +43,10 @@ builder.Services .AddOptions() .BindConfiguration(HeimdallOptions.SectionName); +builder.Services + .AddOptions() + .BindConfiguration(BridgeOptions.SectionName); + builder.Services.AddRazorPages(options => { options.Conventions.AuthorizePage("/Membership/Status"); @@ -73,6 +79,8 @@ builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -164,7 +172,7 @@ builder.Services.AddAuthorization(options => }); var app = builder.Build(); -var eveJsonSerializerOptions = CreateEveJsonSerializerOptions(); +var appJsonSerializerOptions = CreateAppJsonSerializerOptions(); app.Services.GetRequiredService().Validate(); await using (var scope = app.Services.CreateAsyncScope()) @@ -317,6 +325,217 @@ app.MapPost("/heimdall/patron-support/events", async ( }; }).AllowAnonymous(); +var bridgeActions = app.MapGroup("/bridge/actions"); + +bridgeActions.MapPost("/request", async ( + HttpRequest request, + BridgeActionRequest command, + ICurrentBifrostActorAccessor actorAccessor, + BridgeActionService bridgeActionService, + IOptions bridgeOptions, + CancellationToken cancellationToken) => +{ + var actor = await actorAccessor.GetAsync(cancellationToken); + var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value); + if (!caller.IsAllowedTransport) + { + return Results.Unauthorized(); + } + + var result = await bridgeActionService.RequestAsync(command, caller, cancellationToken); + return result.Status == BridgeActionStatus.Denied + ? JsonResult(result, appJsonSerializerOptions, StatusCodes.Status403Forbidden) + : JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted); +}); + +bridgeActions.MapGet("/{id:guid}", async ( + Guid id, + HttpRequest request, + ICurrentBifrostActorAccessor actorAccessor, + BifrostDbContext dbContext, + IOptions bridgeOptions, + CancellationToken cancellationToken) => +{ + var actor = await actorAccessor.GetAsync(cancellationToken); + var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value); + if (!caller.IsAllowedTransport) + { + return Results.Unauthorized(); + } + + var action = await dbContext.BridgeActions + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Id == id, cancellationToken); + + if (action is null) + { + return Results.NotFound(); + } + + if (!caller.CanOperate(action)) + { + return Results.Forbid(); + } + + return JsonResult(BridgeActionResult.From(action), appJsonSerializerOptions); +}); + +bridgeActions.MapPost("/{id:guid}/start", async ( + Guid id, + HttpRequest request, + ICurrentBifrostActorAccessor actorAccessor, + BridgeActionService bridgeActionService, + IOptions bridgeOptions, + CancellationToken cancellationToken) => +{ + var actor = await actorAccessor.GetAsync(cancellationToken); + var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value); + if (!caller.IsAllowedTransport) + { + return Results.Unauthorized(); + } + + try + { + var result = await bridgeActionService.StartAsync(id, caller, cancellationToken); + return result is null + ? Results.NotFound() + : JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted); + } + catch (UnauthorizedAccessException) + { + return Results.Forbid(); + } + catch (BridgeActionException error) + { + return Results.BadRequest(error.Message); + } +}); + +bridgeActions.MapPost("/{id:guid}/complete", async ( + Guid id, + HttpRequest request, + BridgeActionReceiptRequest command, + ICurrentBifrostActorAccessor actorAccessor, + BridgeActionService bridgeActionService, + IOptions bridgeOptions, + CancellationToken cancellationToken) => +{ + var actor = await actorAccessor.GetAsync(cancellationToken); + var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value); + if (!caller.IsAllowedTransport) + { + return Results.Unauthorized(); + } + + try + { + var result = await bridgeActionService.CompleteAsync(id, command, caller, cancellationToken); + return result is null + ? Results.NotFound() + : JsonResult(result, appJsonSerializerOptions); + } + catch (UnauthorizedAccessException) + { + return Results.Forbid(); + } + catch (BridgeActionException error) + { + return Results.BadRequest(error.Message); + } +}); + +bridgeActions.MapPost("/{id:guid}/fail", async ( + Guid id, + HttpRequest request, + BridgeActionFailureRequest command, + ICurrentBifrostActorAccessor actorAccessor, + BridgeActionService bridgeActionService, + IOptions bridgeOptions, + CancellationToken cancellationToken) => +{ + var actor = await actorAccessor.GetAsync(cancellationToken); + var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value); + if (!caller.IsAllowedTransport) + { + return Results.Unauthorized(); + } + + try + { + var result = await bridgeActionService.FailAsync(id, command, caller, cancellationToken); + return result is null + ? Results.NotFound() + : JsonResult(result, appJsonSerializerOptions); + } + catch (UnauthorizedAccessException) + { + return Results.Forbid(); + } +}); + +var dispatchRuns = app.MapGroup("/dispatch/runs"); + +dispatchRuns.MapPost("/start", async ( + HttpRequest request, + AgentDispatchRunStartRequest command, + ICurrentBifrostActorAccessor actorAccessor, + AgentDispatchRunService agentDispatchRunService, + IOptions bridgeOptions, + CancellationToken cancellationToken) => +{ + var actor = await actorAccessor.GetAsync(cancellationToken); + if (!HasValidLocalBridgeToken(request, bridgeOptions.Value) && !actor.IsActiveMember) + { + return Results.Unauthorized(); + } + + var result = await agentDispatchRunService.StartAsync(command, actor.UserAccount?.Id, cancellationToken); + return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted); +}); + +dispatchRuns.MapPost("/{id:guid}/complete", async ( + Guid id, + HttpRequest request, + AgentDispatchRunCompletionRequest command, + ICurrentBifrostActorAccessor actorAccessor, + AgentDispatchRunService agentDispatchRunService, + IOptions bridgeOptions, + CancellationToken cancellationToken) => +{ + var actor = await actorAccessor.GetAsync(cancellationToken); + if (!HasValidLocalBridgeToken(request, bridgeOptions.Value) && !actor.IsActiveMember) + { + return Results.Unauthorized(); + } + + var result = await agentDispatchRunService.CompleteAsync(id, command, actor.UserAccount?.Id, cancellationToken); + return result is null + ? Results.NotFound() + : JsonResult(result, appJsonSerializerOptions); +}); + +dispatchRuns.MapPost("/{id:guid}/fail", async ( + Guid id, + HttpRequest request, + AgentDispatchRunFailureRequest command, + ICurrentBifrostActorAccessor actorAccessor, + AgentDispatchRunService agentDispatchRunService, + IOptions bridgeOptions, + CancellationToken cancellationToken) => +{ + var actor = await actorAccessor.GetAsync(cancellationToken); + if (!HasValidLocalBridgeToken(request, bridgeOptions.Value) && !actor.IsActiveMember) + { + return Results.Unauthorized(); + } + + var result = await agentDispatchRunService.FailAsync(id, command, actor.UserAccount?.Id, cancellationToken); + return result is null + ? Results.NotFound() + : JsonResult(result, appJsonSerializerOptions); +}); + var eveGovernance = app.MapGroup("/eve/governance") .RequireAuthorization(ActiveMemberRequirement.PolicyName); @@ -330,7 +549,7 @@ eveGovernance.MapGet("/surface", async ( var state = await motionGovernanceService.GetStateAsync(actor, cancellationToken); var surface = motionEveSurfaceService.BuildSurface(state); return Results.Text( - JsonSerializer.Serialize(surface, eveJsonSerializerOptions), + JsonSerializer.Serialize(surface, appJsonSerializerOptions), "application/json"); }); @@ -401,7 +620,7 @@ eveGovernance.MapPost("/commands", async ( var state = await motionGovernanceService.GetStateAsync(actor, cancellationToken); var surface = motionEveSurfaceService.BuildSurface(state); return Results.Text( - JsonSerializer.Serialize(surface, eveJsonSerializerOptions), + JsonSerializer.Serialize(surface, appJsonSerializerOptions), "application/json"); }); @@ -443,11 +662,39 @@ app.MapRazorPages(); app.Run(); -static JsonSerializerOptions CreateEveJsonSerializerOptions() +static IResult JsonResult(T value, JsonSerializerOptions options, int statusCode = StatusCodes.Status200OK) => + Results.Text( + JsonSerializer.Serialize(value, options), + "application/json", + statusCode: statusCode); + +static JsonSerializerOptions CreateAppJsonSerializerOptions() { var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); options.Converters.Add(new JsonStringEnumConverter()); return options; } +static BridgeCaller BuildBridgeCaller( + CurrentBifrostActor actor, + HttpRequest request, + BridgeOptions bridgeOptions) +{ + var isLocalBridge = HasValidLocalBridgeToken(request, bridgeOptions); + + return new BridgeCaller( + actor.IsActiveMember, + isLocalBridge, + actor.UserAccount?.Id, + actor.DisplayName); +} + +static bool HasValidLocalBridgeToken(HttpRequest request, BridgeOptions bridgeOptions) +{ + var bridgeToken = request.Headers["X-Bifrost-Bridge-Token"].ToString(); + return + bridgeOptions.HasLocalBridgeToken && + string.Equals(bridgeToken, bridgeOptions.LocalBridgeToken, StringComparison.Ordinal); +} + public partial class Program; diff --git a/tests/Bifrost.Web.Tests/BridgeCliTests.cs b/tests/Bifrost.Web.Tests/BridgeCliTests.cs new file mode 100644 index 0000000..0a9b2a6 --- /dev/null +++ b/tests/Bifrost.Web.Tests/BridgeCliTests.cs @@ -0,0 +1,81 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace Bifrost.Web.Tests; + +public sealed class BridgeCliTests +{ + [Fact] + public async Task GitHub_bridge_command_fails_closed_without_bifrost_gate() + { + var result = await RunNodeAsync([ + "tools/bifrost-bridge.mjs", + "github-pr-comment", + "--repo-root", RepoRoot, + "--identity", "nibu", + "--pr", "1", + "--content", "test comment", + "--target-repository-full-name", "GameCult/Bifrost", + ]); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("require Bifrost authorization", result.Stderr, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GitHub_bridge_command_allows_explicit_operator_recovery_dry_run() + { + var result = await RunNodeAsync([ + "tools/bifrost-bridge.mjs", + "github-pr-comment", + "--repo-root", RepoRoot, + "--identity", "nibu", + "--pr", "1", + "--content", "test comment", + "--target-repository-full-name", "GameCult/Bifrost", + "--allow-ungated-github", "true", + "--dry-run", "true", + ]); + + Assert.Equal(0, result.ExitCode); + using var payload = JsonDocument.Parse(result.Stdout); + Assert.Equal("github-pr-comment", payload.RootElement.GetProperty("action").GetString()); + Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean()); + } + + private static string RepoRoot => + Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); + + private static async Task RunNodeAsync(string[] args) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "node", + WorkingDirectory = RepoRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + } + }; + + foreach (var arg in args) + { + process.StartInfo.ArgumentList.Add(arg); + } + + process.StartInfo.Environment["BIFROST_BRIDGE_BASE_URL"] = string.Empty; + process.StartInfo.Environment["BIFROST_BRIDGE_TOKEN"] = string.Empty; + process.StartInfo.Environment["BIFROST_ALLOW_UNGATED_GITHUB"] = string.Empty; + + process.Start(); + var stdout = await process.StandardOutput.ReadToEndAsync(); + var stderr = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return new ProcessResult(process.ExitCode, stdout, stderr); + } + + private sealed record ProcessResult(int ExitCode, string Stdout, string Stderr); +} diff --git a/tests/Bifrost.Web.Tests/MemberConsoleTests.cs b/tests/Bifrost.Web.Tests/MemberConsoleTests.cs index 5ad66f4..f6a7b7f 100644 --- a/tests/Bifrost.Web.Tests/MemberConsoleTests.cs +++ b/tests/Bifrost.Web.Tests/MemberConsoleTests.cs @@ -1,6 +1,9 @@ using System.Net; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; using Bifrost.Web.Tests.Support; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; namespace Bifrost.Web.Tests; @@ -38,4 +41,73 @@ public sealed class MemberConsoleTests : IClassFixture(); + var actor = dbContext.UserAccounts.Single(x => x.NormalizedGitHubLogin == "TEST-ADMIN"); + dbContext.BridgeActions.Add(new BridgeAction + { + ActorKind = BridgeActorKind.Agent, + ActorName = "nibu", + ActorUserAccountId = actor.Id, + TargetSurface = BridgeTargetSurface.GitHub, + ActionKind = BridgeActionKind.GitHubDraftPullRequest, + Status = BridgeActionStatus.Completed, + TargetRepositoryFullName = "gamecult/bifrost", + TargetLocator = "pulls", + SourceKind = "bifrost_governance_topic", + SourceId = "topic_123", + AuthorityReference = "dispatch-approved", + PolicyDecision = "Authorized through Bifrost policy.", + Title = "Draft motion implementation PR", + Summary = "Open a draft PR for the approved topic.", + ReceiptUrl = "https://github.com/GameCult/Bifrost/pull/99", + ExternalReceiptId = "99", + ReceiptPayload = "{\"pr\":99}", + RequestedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-5), + UpdatedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1), + CompletedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1) + }); + dbContext.AgentDispatchRuns.Add(new AgentDispatchRun + { + RequestId = "req_dispatch_123", + TargetRepoName = "Bifrost", + TargetRepositoryFullName = "GameCult/Bifrost", + TargetAgentIdentity = "nibu", + LaunchMode = "app-server", + Status = AgentDispatchRunStatus.Completed, + StartedByUserAccountId = actor.Id, + WorkerProcessId = 4242, + ThreadId = "thread_1", + TurnId = "turn_1", + LogPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_123/codex.log", + ResultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_123/result.json", + Note = "Codex app turn completed for thread_1/turn_1.", + StartedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-8), + UpdatedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-2), + CompletedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-2) + }); + dbContext.SaveChanges(); + } + + using var client = _factory.CreateClient(); + using var response = await client.GetAsync("/App"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var html = await response.Content.ReadAsStringAsync(); + Assert.Contains("Agent runs", html); + Assert.Contains("req_dispatch_123", html); + Assert.Contains("Bridge receipts", html); + Assert.Contains("Draft motion implementation PR", html); + Assert.Contains("nibu", html); + } } diff --git a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs index d1839a6..2ef082a 100644 --- a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs +++ b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -294,6 +295,217 @@ public sealed class PlatformEndpointsTests : IClassFixture x.Action == "motion.voted"); } + [Fact] + public async Task Local_bridge_token_can_authorize_and_receipt_agent_github_action() + { + using var client = _factory.CreateClient(); + + var requestPayload = JsonSerializer.Serialize(new + { + actorKind = "Agent", + actorName = "nibu", + targetSurface = "GitHub", + actionKind = "GitHubDraftPullRequest", + targetRepositoryFullName = "GameCult/Bifrost", + targetLocator = "pulls", + sourceKind = "governance_topic", + sourceId = "topic_123", + authorityReference = "dispatch-approved", + title = "Draft motion implementation PR", + summary = "Open a draft PR for the approved topic." + }); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); + request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); + request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); + + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + var action = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(action); + Assert.Equal("Authorized", action.Status); + + using var startRequest = new HttpRequestMessage(HttpMethod.Post, $"/bridge/actions/{action.Id}/start"); + startRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); + using var startResponse = await client.SendAsync(startRequest); + Assert.Equal(HttpStatusCode.Accepted, startResponse.StatusCode); + + var completionPayload = JsonSerializer.Serialize(new + { + receiptUrl = "https://github.com/GameCult/Bifrost/pull/99", + externalReceiptId = "99", + receiptPayload = "{\"pr\":99}" + }); + using var completeRequest = new HttpRequestMessage(HttpMethod.Post, $"/bridge/actions/{action.Id}/complete"); + completeRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); + completeRequest.Content = new StringContent(completionPayload, Encoding.UTF8, "application/json"); + + using var completeResponse = await client.SendAsync(completeRequest); + + Assert.Equal(HttpStatusCode.OK, completeResponse.StatusCode); + + using var scope = _factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); + + Assert.Equal(BridgeActionStatus.Completed, savedAction.Status); + Assert.Equal("https://github.com/GameCult/Bifrost/pull/99", savedAction.ReceiptUrl); + Assert.Equal("99", savedAction.ExternalReceiptId); + Assert.Contains(dbContext.AuditEvents, x => x.EntityType == nameof(BridgeAction) && x.Action == "bridge.completed"); + } + + [Fact] + public async Task Agent_github_action_without_provenance_is_denied_and_recorded() + { + using var client = _factory.CreateClient(); + + var requestPayload = JsonSerializer.Serialize(new + { + actorKind = "Agent", + actorName = "nibu", + targetSurface = "GitHub", + actionKind = "GitHubDraftPullRequest", + targetRepositoryFullName = "GameCult/Bifrost", + targetLocator = "pulls", + title = "Unproven draft", + summary = "This should not pass policy." + }); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request"); + request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); + request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json"); + + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var action = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(action); + Assert.Equal("Denied", action.Status); + + using var scope = _factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id); + + Assert.Equal(BridgeActionStatus.Denied, savedAction.Status); + Assert.Contains("must cite", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase); + Assert.Contains(dbContext.AuditEvents, x => x.EntityType == nameof(BridgeAction) && x.Action == "bridge.denied"); + } + + [Fact] + public async Task Local_bridge_token_can_record_dispatch_run_lifecycle() + { + using var client = _factory.CreateClient(); + + var startPayload = JsonSerializer.Serialize(new + { + requestId = "req_dispatch_123", + targetRepoName = "Bifrost", + targetRepositoryFullName = "GameCult/Bifrost", + targetAgentIdentity = "nibu", + launchMode = "app-server", + workerProcessId = 4242, + threadId = "thread_1", + turnId = "turn_1", + logPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_123/codex.log", + resultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_123/result.json", + note = "Codex app-server turn started." + }); + + using var startRequest = new HttpRequestMessage(HttpMethod.Post, "/dispatch/runs/start"); + startRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); + startRequest.Content = new StringContent(startPayload, Encoding.UTF8, "application/json"); + + using var startResponse = await client.SendAsync(startRequest); + + Assert.Equal(HttpStatusCode.Accepted, startResponse.StatusCode); + var started = await startResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(started); + Assert.Equal("Started", started.Status); + + var completePayload = JsonSerializer.Serialize(new + { + status = "Completed", + threadId = "thread_1", + turnId = "turn_1", + resultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_123/result.json", + note = "Codex app turn completed for thread_1/turn_1." + }); + + using var completeRequest = new HttpRequestMessage(HttpMethod.Post, $"/dispatch/runs/{started.Id}/complete"); + completeRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); + completeRequest.Content = new StringContent(completePayload, Encoding.UTF8, "application/json"); + + using var completeResponse = await client.SendAsync(completeRequest); + + Assert.Equal(HttpStatusCode.OK, completeResponse.StatusCode); + + using var scope = _factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var savedRun = dbContext.AgentDispatchRuns.Single(x => x.Id == started.Id); + + Assert.Equal(AgentDispatchRunStatus.Completed, savedRun.Status); + Assert.Equal("req_dispatch_123", savedRun.RequestId); + Assert.Equal("turn_1", savedRun.TurnId); + Assert.Contains(dbContext.AuditEvents, x => x.EntityType == nameof(AgentDispatchRun) && x.Action == "agent-dispatch.completed"); + } + + [Fact] + public async Task Local_bridge_token_can_record_dispatch_run_failure() + { + using var client = _factory.CreateClient(); + + var startPayload = JsonSerializer.Serialize(new + { + requestId = "req_dispatch_fail_123", + targetRepoName = "Bifrost", + targetRepositoryFullName = "GameCult/Bifrost", + targetAgentIdentity = "nibu", + launchMode = "app-server", + workerProcessId = 4242, + threadId = string.Empty, + turnId = string.Empty, + logPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_fail_123/codex.log", + resultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_fail_123/result.json", + note = "Codex app-server launch started." + }); + + using var startRequest = new HttpRequestMessage(HttpMethod.Post, "/dispatch/runs/start"); + startRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); + startRequest.Content = new StringContent(startPayload, Encoding.UTF8, "application/json"); + + using var startResponse = await client.SendAsync(startRequest); + + Assert.Equal(HttpStatusCode.Accepted, startResponse.StatusCode); + var started = await startResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(started); + + var failPayload = JsonSerializer.Serialize(new + { + threadId = string.Empty, + turnId = string.Empty, + resultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_fail_123/result.json", + note = "Codex dispatch failed.", + error = "Codex app-server thread/start returned no thread id." + }); + + using var failRequest = new HttpRequestMessage(HttpMethod.Post, $"/dispatch/runs/{started.Id}/fail"); + failRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token"); + failRequest.Content = new StringContent(failPayload, Encoding.UTF8, "application/json"); + + using var failResponse = await client.SendAsync(failRequest); + + Assert.Equal(HttpStatusCode.OK, failResponse.StatusCode); + + using var scope = _factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var savedRun = dbContext.AgentDispatchRuns.Single(x => x.Id == started.Id); + + Assert.Equal(AgentDispatchRunStatus.Failed, savedRun.Status); + Assert.Equal("Codex app-server thread/start returned no thread id.", savedRun.Error); + Assert.Contains(dbContext.AuditEvents, x => x.EntityType == nameof(AgentDispatchRun) && x.Action == "agent-dispatch.failed"); + } + private static string ComputeSignature(string secret, string payload) { using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); @@ -308,4 +520,18 @@ public sealed class PlatformEndpointsTests : IClassFixture ["GitHubApp:WebhookSecret"] = "test-webhook-secret", ["GitHubApp:PrivateKeyPem"] = "test-private-key", ["GitHubApp:AppId"] = "1", + ["Bridge:LocalBridgeToken"] = "test-bridge-token", ["Host:PublicBaseUrl"] = "https://localhost", ["Host:ExpectedHost"] = "localhost", ["Host:RequireStrictHostValidation"] = "false", diff --git a/tools/agent-transport.mjs b/tools/agent-transport.mjs index 4646033..07f8bae 100644 --- a/tools/agent-transport.mjs +++ b/tools/agent-transport.mjs @@ -194,6 +194,10 @@ async function mirrorEnqueuedRequestOrThrow(request, options) { "--channel-id", channelId, "--content", mirrorContent, "--persona-name", personaName, + "--source-kind", documentType, + "--source-id", request.id, + "--authority-ref", "bifrost_agent_transport_enqueue", + ...optionalArg("--target-repository-full-name", request.targetRepositoryFullName ?? request.targetRepoName), ...optionalArg("--persona-avatar-url", personaAvatarUrl), ...optionalArg("--reply-to-message-id", options["mirror-reply-to-message-id"]), ...(options["mirror-dry-run"] === "true" ? ["--dry-run", "true"] : []), diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index 7a97242..c79d309 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -45,6 +45,7 @@ async function main() { } async function createGitHubDraftPr(options) { + ensureGitHubBridgeGate(options); const repoRoot = resolve(requireOption(options, "repo-root")); const identity = slugify(requireOption(options, "identity")); const title = requireOption(options, "title"); @@ -56,6 +57,9 @@ async function createGitHubDraftPr(options) { const commitMessage = options["commit-message"] ?? title; const allowDirty = options["allow-dirty"] === "true"; const dryRun = options["dry-run"] === "true"; + const targetRepositoryFullName = + optionalString(options["target-repository-full-name"]) ?? + detectGitHubRepositoryFullName(repoRoot); const originalBranch = git(["branch", "--show-current"], repoRoot).stdout.trim(); const status = git(["status", "--short"], repoRoot).stdout.trim(); @@ -85,31 +89,59 @@ async function createGitHubDraftPr(options) { return; } + const bridgeAction = await beginBridgeAction(options, { + actorKind: "Agent", + actorName: identity, + targetSurface: "GitHub", + actionKind: "GitHubDraftPullRequest", + targetRepositoryFullName, + targetLocator: relativePath, + title, + summary: body, + }); + let pushed = false; let prUrl = ""; try { - git(["switch", "-c", branch], repoRoot); - await mkdir(dirname(targetPath), { recursive: true }); - await writeFile(targetPath, content, "utf8"); - git(["add", "--", relativePath], repoRoot); - git(["commit", "-m", commitMessage], repoRoot); - git(["push", "-u", "origin", branch], repoRoot); - pushed = true; + try { + await bridgeAction?.start(); + git(["switch", "-c", branch], repoRoot); + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, content, "utf8"); + git(["add", "--", relativePath], repoRoot); + git(["commit", "-m", commitMessage], repoRoot); + git(["push", "-u", "origin", branch], repoRoot); + pushed = true; - const pr = run("gh", [ - "pr", - "create", - "--draft", - "--title", - title, - "--body", - body, - "--base", - base, - "--head", - branch, - ], repoRoot); - prUrl = pr.stdout.trim(); + const pr = run("gh", [ + "pr", + "create", + "--draft", + "--title", + title, + "--body", + body, + "--base", + base, + "--head", + branch, + ], repoRoot); + prUrl = pr.stdout.trim(); + await bridgeAction?.complete({ + receiptUrl: prUrl, + externalReceiptId: extractGitHubNumber(prUrl) ?? branch, + receiptPayload: JSON.stringify({ + branch, + base, + path: relativePath, + commitMessage, + prUrl, + }), + }); + } catch (error) { + await bridgeAction?.fail(error); + throw error; + } } finally { if (originalBranch) { git(["switch", originalBranch], repoRoot, { allowFailure: true }); @@ -131,12 +163,16 @@ async function createGitHubDraftPr(options) { } async function commentGitHubPr(options) { + ensureGitHubBridgeGate(options); const repoRoot = resolve(requireOption(options, "repo-root")); const identity = slugify(requireOption(options, "identity")); const pr = requireOption(options, "pr"); const content = await readOptionText(options, "content", "content-file"); const dryRun = options["dry-run"] === "true"; const body = `${identity} says:\n\n${content.trim()}`; + const targetRepositoryFullName = + optionalString(options["target-repository-full-name"]) ?? + detectGitHubRepositoryFullName(repoRoot); if (dryRun) { printJson({ @@ -150,7 +186,34 @@ async function commentGitHubPr(options) { return; } - run("gh", ["pr", "comment", pr, "--body", body], repoRoot); + const bridgeAction = await beginBridgeAction(options, { + actorKind: "Agent", + actorName: identity, + targetSurface: "GitHub", + actionKind: "GitHubPullRequestComment", + targetRepositoryFullName, + targetLocator: `pull/${pr}`, + title: `PR comment on #${pr}`, + summary: content, + }); + + try { + await bridgeAction?.start(); + run("gh", ["pr", "comment", pr, "--body", body], repoRoot); + await bridgeAction?.complete({ + receiptUrl: `https://github.com/${targetRepositoryFullName}/pull/${pr}#issuecomment-bridge`, + externalReceiptId: String(pr), + receiptPayload: JSON.stringify({ + pullRequestNumber: Number(pr), + repositoryFullName: targetRepositoryFullName, + author: identity, + body, + }), + }); + } catch (error) { + await bridgeAction?.fail(error); + throw error; + } printJson({ action: "github-pr-comment", ok: true, @@ -186,13 +249,41 @@ async function postDiscordMessage(options) { throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before posting to Discord."); } - const result = personaName - ? await postDiscordPersonaMessage(token, channelId, content, { - personaName, - personaAvatarUrl, - replyToMessageId, - }) - : await postDiscordBotMessage(token, channelId, content, replyToMessageId); + const bridgeAction = await beginBridgeAction(options, { + actorKind: personaName ? "Persona" : "Service", + actorName: personaName ?? "bifrost", + targetSurface: "Discord", + actionKind: "DiscordPost", + targetRepositoryFullName: options["target-repository-full-name"] ?? "", + targetLocator: `channel/${channelId}`, + title: personaName ? `Discord post from ${personaName}` : "Discord post", + summary: content, + }); + + let result; + try { + await bridgeAction?.start(); + result = personaName + ? await postDiscordPersonaMessage(token, channelId, content, { + personaName, + personaAvatarUrl, + replyToMessageId, + }) + : await postDiscordBotMessage(token, channelId, content, replyToMessageId); + await bridgeAction?.complete({ + receiptUrl: `https://discord.com/channels/${result.guildId ?? "@me"}/${channelId}/${result.id}`, + externalReceiptId: result.id, + receiptPayload: JSON.stringify({ + channelId, + messageId: result.id, + guildId: result.guildId ?? "", + transport: result.transport, + }), + }); + } catch (error) { + await bridgeAction?.fail(error); + throw error; + } printJson({ action: "discord-post", @@ -224,8 +315,37 @@ async function sendDiscordDm(options) { throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM."); } - const channelId = await openDiscordDmChannel(token, recipientId); - const result = await postDiscordBotMessage(token, channelId, content, undefined); + const bridgeAction = await beginBridgeAction(options, { + actorKind: "Service", + actorName: "bifrost", + targetSurface: "Discord", + actionKind: "DiscordDirectMessage", + targetRepositoryFullName: options["target-repository-full-name"] ?? "", + targetLocator: `recipient/${recipientId}`, + title: "Discord direct message", + summary: content, + }); + + let channelId; + let result; + try { + await bridgeAction?.start(); + channelId = await openDiscordDmChannel(token, recipientId); + result = await postDiscordBotMessage(token, channelId, content, undefined); + await bridgeAction?.complete({ + receiptUrl: `https://discord.com/channels/@me/${channelId}/${result.id}`, + externalReceiptId: result.id, + receiptPayload: JSON.stringify({ + recipientId, + channelId, + messageId: result.id, + transport: result.transport, + }), + }); + } catch (error) { + await bridgeAction?.fail(error); + throw error; + } printJson({ action: "discord-dm", ok: true, @@ -260,15 +380,45 @@ async function postRedditThread(options) { return; } - const accessToken = await getRedditAccessToken(); - const result = await submitRedditSelfPost(accessToken, { - subreddit, + const bridgeAction = await beginBridgeAction(options, { + actorKind: personaName ? "Persona" : "Service", + actorName: personaName ?? "bifrost", + targetSurface: "Reddit", + actionKind: "RedditPost", + targetRepositoryFullName: options["target-repository-full-name"] ?? "", + targetLocator: `r/${subreddit}`, title, - content, - flairId: personaFlairId, - flairText: personaFlairText, + summary: content, }); + let result; + try { + await bridgeAction?.start(); + const accessToken = await getRedditAccessToken(); + result = await submitRedditSelfPost(accessToken, { + subreddit, + title, + content, + flairId: personaFlairId, + flairText: personaFlairText, + }); + await bridgeAction?.complete({ + receiptUrl: result.url, + externalReceiptId: result.thingId, + receiptPayload: JSON.stringify({ + subreddit, + thingId: result.thingId, + url: result.url, + personaName, + personaFlairId, + personaFlairText, + }), + }); + } catch (error) { + await bridgeAction?.fail(error); + throw error; + } + printJson({ action: "reddit-post", ok: true, @@ -743,6 +893,140 @@ function optionalString(value) { return trimmed.length > 0 ? trimmed : undefined; } +async function beginBridgeAction(options, action) { + const baseUrl = optionalString(options["bifrost-base-url"]) ?? optionalString(process.env.BIFROST_BRIDGE_BASE_URL); + if (!baseUrl) { + return null; + } + + const token = optionalString(options["bifrost-token"]) ?? optionalString(process.env.BIFROST_BRIDGE_TOKEN); + if (!token) { + throw new Error("Set BIFROST_BRIDGE_TOKEN or pass --bifrost-token when using the Bifrost bridge action ledger."); + } + + const payload = { + ...action, + sourceKind: optionalString(options["source-kind"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_KIND) ?? "", + sourceId: optionalString(options["source-id"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_ID) ?? "", + authorityReference: optionalString(options["authority-ref"]) ?? optionalString(process.env.BIFROST_BRIDGE_AUTHORITY_REF) ?? "", + workItemId: optionalString(options["work-item-id"]) ?? null, + motionId: optionalString(options["motion-id"]) ?? null, + }; + + const requested = await postBridgeJson( + baseUrl, + token, + "/bridge/actions/request", + payload, + new Set([202, 403]), + ); + + if (requested.status === 403) { + throw new Error(`Bifrost denied bridge action: ${requested.body?.policyDecision ?? requested.text}`); + } + + const id = requested.body?.id; + if (!id) { + throw new Error(`Bifrost bridge request returned no action id: ${requested.text}`); + } + + return { + id, + async start() { + await postBridgeJson(baseUrl, token, `/bridge/actions/${id}/start`, undefined, new Set([202])); + }, + async complete(receipt) { + await postBridgeJson(baseUrl, token, `/bridge/actions/${id}/complete`, receipt, new Set([200])); + }, + async fail(error) { + const failureReason = error instanceof Error ? error.message : String(error); + try { + await postBridgeJson( + baseUrl, + token, + `/bridge/actions/${id}/fail`, + { failureReason }, + new Set([200, 400]), + ); + } catch { + // Best effort: the underlying command error is still the real failure. + } + }, + }; +} + +function ensureGitHubBridgeGate(options) { + const baseUrl = optionalString(options["bifrost-base-url"]) ?? optionalString(process.env.BIFROST_BRIDGE_BASE_URL); + const token = optionalString(options["bifrost-token"]) ?? optionalString(process.env.BIFROST_BRIDGE_TOKEN); + const allowUngated = + options["allow-ungated-github"] === "true" || + process.env.BIFROST_ALLOW_UNGATED_GITHUB === "true"; + + if (baseUrl && token) { + return; + } + + if (allowUngated) { + return; + } + + throw new Error( + "GitHub bridge actions require Bifrost authorization and receipt logging. " + + "Set BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN, or use --allow-ungated-github true only for explicit operator recovery.", + ); +} + +async function postBridgeJson(baseUrl, token, path, payload, allowedStatuses) { + const url = new URL(path, ensureTrailingSlash(baseUrl)); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Bifrost-Bridge-Token": token, + }, + body: payload === undefined ? undefined : JSON.stringify(payload), + }); + + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = null; + } + } + + if (!allowedStatuses.has(response.status)) { + throw new Error(`Bifrost bridge call to ${path} failed with ${response.status}: ${text}`); + } + + return { + status: response.status, + text, + body, + }; +} + +function ensureTrailingSlash(value) { + return value.endsWith("/") ? value : `${value}/`; +} + +function detectGitHubRepositoryFullName(repoRoot) { + const result = run( + "gh", + ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], + repoRoot, + { allowFailure: true }, + ); + return optionalString(result.stdout) ?? ""; +} + +function extractGitHubNumber(value) { + const match = optionalString(value)?.match(/\/(\d+)(?:[#?].*)?$/); + return match ? match[1] : undefined; +} + function normalizeSubreddit(value) { const subreddit = optionalString(value)?.replace(/^\/?r\//i, ""); if (!subreddit || !/^[A-Za-z0-9_]{3,21}$/.test(subreddit)) { @@ -829,6 +1113,10 @@ Examples: node tools/bifrost-bridge.mjs discord-post --channel-id 1501196543150264332 --persona-name Nibu --content "Draft PR opened: https://github.com/..." node tools/bifrost-bridge.mjs discord-dm --recipient-id 123456789 --content "Moderation status update..." node tools/bifrost-bridge.mjs reddit-post --title "Nibu: Reset-loop continuity" --persona-name Nibu --content-file thread.md + +GitHub note: + GitHub actions require BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can gate and receipt the crossing. + Use --allow-ungated-github true only for explicit operator recovery. `); } diff --git a/tools/dispatch-agent-requests.mjs b/tools/dispatch-agent-requests.mjs index e1d969a..3c5cd01 100644 --- a/tools/dispatch-agent-requests.mjs +++ b/tools/dispatch-agent-requests.mjs @@ -137,61 +137,125 @@ async function runClaimedViaCodexExec(request, repoRoot, promptPath, logPath, op const reasoningEffort = options["reasoning-effort"] ?? process.env.CODEX_MODEL_REASONING_EFFORT ?? "medium"; const sandbox = options.sandbox ?? "workspace-write"; const startedAt = new Date().toISOString(); - + const bridgeContextEnv = buildBridgeContextEnv(request); + const resultPath = resolve(dirname(logPath), "result.json"); await mkdir(dirname(logPath), { recursive: true }); appendLog(logPath, `started ${startedAt}`); appendLog(logPath, `request ${request.id}: ${request.title}`); appendLog(logPath, `repoRoot ${repoRoot}`); + let dispatchRun = null; - const prompt = await readFile(promptPath, "utf8"); - const result = spawnSync( - codexExecutable, - [ - ...codexExecArgs, - "exec", - "-m", model, - "-c", 'approval_policy="never"', - "-c", `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`, - "--skip-git-repo-check", - "-s", sandbox, - "-", - ], - { - cwd: repoRoot, - input: prompt, - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - }, - ); + try { + dispatchRun = await beginDispatchRun(request, { + launchMode: "codex-exec", + workerProcessId: process.pid, + threadId: "", + turnId: "", + logPath, + resultPath, + note: "Codex dispatch process started.", + }); - appendLog(logPath, result.stdout ?? ""); - appendLog(logPath, result.stderr ?? ""); - const ok = result.status === 0; - const closeStatus = ok ? "completed" : "cancelled"; - const note = ok - ? `Codex dispatch completed from Bifrost. Log: ${logPath}` - : `Codex dispatch failed with exit ${result.status ?? "unknown"}. Log: ${logPath}`; - const close = runNodeJson([ - transportCli, - "close", - "--id", request.id, - "--status", closeStatus, - "--note", note, - ], bifrostRoot); - await writeFile( - resolve(dirname(logPath), "result.json"), - `${JSON.stringify({ + const prompt = await readFile(promptPath, "utf8"); + const result = spawnSync( + codexExecutable, + [ + ...codexExecArgs, + "exec", + "-m", model, + "-c", 'approval_policy="never"', + "-c", `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`, + "--skip-git-repo-check", + "-s", sandbox, + "-", + ], + { + cwd: repoRoot, + input: prompt, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + ...bridgeContextEnv, + }, + windowsHide: true, + }, + ); + + appendLog(logPath, result.stdout ?? ""); + appendLog(logPath, result.stderr ?? ""); + const launchError = result.error ? summarizeError(result.error) : ""; + const ok = result.status === 0 && !launchError; + const closeStatus = ok ? "completed" : "cancelled"; + const note = ok + ? `Codex dispatch completed from Bifrost. Log: ${logPath}` + : launchError + ? `Codex dispatch failed before completion: ${launchError}. Log: ${logPath}` + : `Codex dispatch failed with exit ${result.status ?? "unknown"}. Log: ${logPath}`; + const close = runNodeJson([ + transportCli, + "close", + "--id", request.id, + "--status", closeStatus, + "--note", note, + ], bifrostRoot); + + if (launchError) { + await dispatchRun?.fail({ + threadId: "", + turnId: "", + resultPath, + note, + error: launchError, + }); + } else { + await dispatchRun?.complete({ + status: ok ? "Completed" : "Cancelled", + threadId: "", + turnId: "", + resultPath, + note, + }); + } + + await writeDispatchResult(logPath, { requestId: request.id, ok, exitCode: result.status, + error: launchError, launchMode: "codex-exec", startedAt, finishedAt: new Date().toISOString(), close, - }, null, 2)}\n`, - "utf8", - ); + }); + } catch (error) { + const message = summarizeError(error); + const note = `Codex dispatch failed before completion: ${message}. Log: ${logPath}`; + const close = runNodeJson([ + transportCli, + "close", + "--id", request.id, + "--status", "cancelled", + "--note", note, + ], bifrostRoot); + await dispatchRun?.fail({ + threadId: "", + turnId: "", + resultPath, + note, + error: message, + }); + await writeDispatchResult(logPath, { + requestId: request.id, + ok: false, + error: message, + launchMode: "codex-exec", + startedAt, + finishedAt: new Date().toISOString(), + close, + }); + throw error; + } } async function runClaimedViaAppServer(request, repoRoot, promptPath, logPath, options) { @@ -200,9 +264,11 @@ async function runClaimedViaAppServer(request, repoRoot, promptPath, logPath, op const sandbox = options.sandbox ?? "danger-full-access"; const startedAt = new Date().toISOString(); const prompt = await readFile(promptPath, "utf8"); + const resultPath = resolve(dirname(logPath), "result.json"); const client = new CodexAppServerClient({ logPath, command: resolveCodexCommand(options), + env: buildBridgeContextEnv(request), }); await mkdir(dirname(logPath), { recursive: true }); @@ -213,6 +279,15 @@ async function runClaimedViaAppServer(request, repoRoot, promptPath, logPath, op let threadId; let turnId; + const dispatchRun = await beginDispatchRun(request, { + launchMode: "app-server", + workerProcessId: process.pid, + threadId: "", + turnId: "", + logPath, + resultPath, + note: "Codex app-server launch started.", + }); try { await client.start(); await client.request("initialize", { @@ -294,6 +369,13 @@ async function runClaimedViaAppServer(request, repoRoot, promptPath, logPath, op "--status", closeStatus, "--note", note, ], bifrostRoot); + await dispatchRun?.complete({ + status: ok ? "Completed" : "Cancelled", + threadId, + turnId, + resultPath, + note, + }); await writeDispatchResult(logPath, { requestId: request.id, ok, @@ -305,9 +387,10 @@ async function runClaimedViaAppServer(request, repoRoot, promptPath, logPath, op close, }); } catch (error) { - appendLog(logPath, error instanceof Error ? error.stack ?? error.message : String(error)); + const message = summarizeError(error); + appendLog(logPath, message); if (!threadId || !turnId) { - releaseClaimedRequest(request.id, `Visible Codex app turn did not start: ${error instanceof Error ? error.message : String(error)}`); + releaseClaimedRequest(request.id, `Visible Codex app turn did not start: ${message}`); } else { runNodeJson([ transportCli, @@ -317,6 +400,13 @@ async function runClaimedViaAppServer(request, repoRoot, promptPath, logPath, op "--note", `Codex app dispatch failed after turn start for ${threadId}/${turnId}.`, ], bifrostRoot); } + await dispatchRun?.fail({ + threadId: threadId ?? "", + turnId: turnId ?? "", + resultPath, + note: "Codex dispatch failed.", + error: message, + }); await writeDispatchResult(logPath, { requestId: request.id, ok: false, @@ -325,7 +415,7 @@ async function runClaimedViaAppServer(request, repoRoot, promptPath, logPath, op turnId, startedAt, finishedAt: new Date().toISOString(), - error: error instanceof Error ? error.message : String(error), + error: message, }); process.exitCode = 1; } finally { @@ -387,6 +477,7 @@ Priority: ${request.priority} Work the request in this workspace. Prefer a small coherent change, create or update tests/docs when appropriate, and leave the repo in a reviewable state. If this is only an analysis/proposal request, produce the artifact requested. Bifrost will close the transport request after this Codex turn exits. Transport policy: do not push to GitHub, open a pull request, or publish external changes unless this specific request explicitly asks for that. A Bifrost-dispatched turn may prepare local changes and report the commit/PR command it would run, but the bridge owns publication policy. +If this request explicitly authorizes a governed crossing and you use \`tools/bifrost-bridge.mjs\`, the dispatch runtime already provides bridge provenance for request \`${request.id}\`. Preserve it. ${request.requestMarkdown} `; @@ -408,6 +499,10 @@ function postDispatchReceipt(request, dispatchRecord, options) { "--channel-id", channelId, "--persona-name", personaName, "--content", content, + "--source-kind", "bifrost_agent_transport_request", + "--source-id", request.id, + "--authority-ref", "bifrost_dispatch_started_receipt", + ...optionalArg("--target-repository-full-name", request.targetRepositoryFullName), ...optionalArg("--persona-avatar-url", personaAvatarUrl), ], bifrostRoot); } @@ -440,9 +535,18 @@ function renderDispatchReceiptContent(request) { ].join("\n"); } +function buildBridgeContextEnv(request) { + return { + BIFROST_BRIDGE_SOURCE_KIND: "bifrost_agent_transport_request", + BIFROST_BRIDGE_SOURCE_ID: request.id, + BIFROST_BRIDGE_AUTHORITY_REF: "bifrost_dispatch_execution", + }; +} + class CodexAppServerClient { - constructor({ command, logPath }) { + constructor({ command, env, logPath }) { this.command = command; + this.env = env; this.logPath = logPath; this.child = undefined; this.nextId = 1; @@ -454,6 +558,10 @@ class CodexAppServerClient { async start() { this.child = spawn(this.command.exe, [...this.command.args, "app-server", "--listen", "stdio://"], { cwd: bifrostRoot, + env: { + ...process.env, + ...this.env, + }, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }); @@ -621,6 +729,108 @@ async function writeDispatchResult(logPath, result) { ); } +async function beginDispatchRun(request, input) { + const baseUrl = optionalString(process.env.BIFROST_BRIDGE_BASE_URL); + const token = optionalString(process.env.BIFROST_BRIDGE_TOKEN); + if (!baseUrl || !token) { + return null; + } + + const started = await postDispatchRunJson( + baseUrl, + token, + "/dispatch/runs/start", + { + requestId: request.id, + targetRepoName: request.targetRepoName, + targetRepositoryFullName: request.targetRepositoryFullName ?? "", + targetAgentIdentity: request.targetAgentIdentity ?? "", + launchMode: input.launchMode, + workerProcessId: input.workerProcessId, + threadId: input.threadId, + turnId: input.turnId, + logPath: input.logPath, + resultPath: input.resultPath, + note: input.note, + }, + new Set([202]), + ); + + const id = started.body?.id; + if (!id) { + throw new Error(`Bifrost dispatch run start returned no run id: ${started.text}`); + } + + return { + async complete(payload) { + await postDispatchRunJson( + baseUrl, + token, + `/dispatch/runs/${id}/complete`, + payload, + new Set([200]), + ); + }, + async fail(payload) { + try { + await postDispatchRunJson( + baseUrl, + token, + `/dispatch/runs/${id}/fail`, + payload, + new Set([200]), + ); + } catch { + // Best effort: the run failure still lands in the local result/log files. + } + }, + }; +} + +async function postDispatchRunJson(baseUrl, token, path, payload, allowedStatuses) { + const url = new URL(path, ensureTrailingSlash(baseUrl)); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Bifrost-Bridge-Token": token, + }, + body: JSON.stringify(payload), + }); + + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = null; + } + } + + if (!allowedStatuses.has(response.status)) { + throw new Error(`Bifrost dispatch run call to ${path} failed with ${response.status}: ${text}`); + } + + return { + status: response.status, + text, + body, + }; +} + +function ensureTrailingSlash(value) { + return value.endsWith("/") ? value : `${value}/`; +} + +function summarizeError(error) { + if (error instanceof Error) { + return error.stack ?? error.message; + } + + return String(error); +} + function summarizeRequestTopic(request) { const title = String(request.title ?? "").trim(); if (title && !/^Recent\b/i.test(title)) { diff --git a/tools/governance-threads.mjs b/tools/governance-threads.mjs index 569a0e5..5fddd89 100644 --- a/tools/governance-threads.mjs +++ b/tools/governance-threads.mjs @@ -372,6 +372,10 @@ async function mirrorTopicActivityOrThrow(cache, topic, input) { "--channel-id", channelId, "--content", mirrorContent, "--persona-name", personaName, + "--source-kind", "bifrost_governance_topic", + "--source-id", topic.id, + "--authority-ref", `bifrost_governance_${input.eventLabel}`, + "--target-repository-full-name", topic.jurisdictionRepoName, ...optionalArg("--persona-avatar-url", personaAvatarUrl), ...optionalArg("--reply-to-message-id", input.options["mirror-reply-to-message-id"]), ...(input.options["mirror-dry-run"] === "true" ? ["--dry-run", "true"] : []),