Gate GitHub bridge actions and receipt agent runs
This commit is contained in:
parent
696cb221de
commit
2281b0e63d
26 changed files with 6264 additions and 98 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
src/Bifrost.Web/Configuration/BridgeOptions.cs
Normal file
10
src/Bifrost.Web/Configuration/BridgeOptions.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -53,6 +53,10 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
|
||||
public DbSet<RevenueShareLine> RevenueShareLines => Set<RevenueShareLine>();
|
||||
|
||||
public DbSet<BridgeAction> BridgeActions => Set<BridgeAction>();
|
||||
|
||||
public DbSet<AgentDispatchRun> AgentDispatchRuns => Set<AgentDispatchRun>();
|
||||
|
||||
public DbSet<AuditEvent> AuditEvents => Set<AuditEvent>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
|
|
@ -395,6 +399,61 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<BridgeAction>(entity =>
|
||||
{
|
||||
entity.Property(x => x.ActorKind).HasConversion<string>();
|
||||
entity.Property(x => x.TargetSurface).HasConversion<string>();
|
||||
entity.Property(x => x.ActionKind).HasConversion<string>();
|
||||
entity.Property(x => x.Status).HasConversion<string>();
|
||||
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<AgentDispatchRun>(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<string>();
|
||||
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<AuditEvent>(entity =>
|
||||
{
|
||||
entity.Property(x => x.EntityType).HasMaxLength(120);
|
||||
|
|
|
|||
1767
src/Bifrost.Web/Data/Migrations/20260617010537_AddBridgeActionLedger.Designer.cs
generated
Normal file
1767
src/Bifrost.Web/Data/Migrations/20260617010537_AddBridgeActionLedger.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,110 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bifrost.Web.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBridgeActionLedger : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BridgeActions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ActorKind = table.Column<string>(type: "text", nullable: false),
|
||||
ActorUserAccountId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
ActorName = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
|
||||
TargetSurface = table.Column<string>(type: "text", nullable: false),
|
||||
ActionKind = table.Column<string>(type: "text", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
WorkItemId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
MotionId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
TargetRepositoryFullName = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
|
||||
TargetLocator = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
SourceKind = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
SourceId = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
|
||||
AuthorityReference = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
|
||||
PolicyDecision = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
|
||||
Summary = table.Column<string>(type: "text", nullable: false),
|
||||
ReceiptUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
ExternalReceiptId = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
|
||||
ReceiptPayload = table.Column<string>(type: "text", nullable: false),
|
||||
FailureReason = table.Column<string>(type: "text", nullable: false),
|
||||
RequestedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
StartedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
CompletedAtUtc = table.Column<DateTimeOffset>(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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BridgeActions");
|
||||
}
|
||||
}
|
||||
}
|
||||
1870
src/Bifrost.Web/Data/Migrations/20260617022305_AddAgentDispatchRuns.Designer.cs
generated
Normal file
1870
src/Bifrost.Web/Data/Migrations/20260617022305_AddAgentDispatchRuns.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,76 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bifrost.Web.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAgentDispatchRuns : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AgentDispatchRuns",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
RequestId = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
|
||||
TargetRepoName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
TargetRepositoryFullName = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
|
||||
TargetAgentIdentity = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
|
||||
LaunchMode = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
StartedByUserAccountId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
WorkerProcessId = table.Column<int>(type: "integer", nullable: true),
|
||||
ThreadId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
TurnId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
LogPath = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
ResultPath = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
Note = table.Column<string>(type: "text", nullable: false),
|
||||
Error = table.Column<string>(type: "text", nullable: false),
|
||||
StartedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
CompletedAtUtc = table.Column<DateTimeOffset>(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" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AgentDispatchRuns");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,97 @@ namespace Bifrost.Web.Data.Migrations
|
|||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LaunchMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("character varying(40)");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RequestId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)");
|
||||
|
||||
b.Property<string>("ResultPath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("StartedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("StartedByUserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TargetAgentIdentity")
|
||||
.IsRequired()
|
||||
.HasMaxLength(160)
|
||||
.HasColumnType("character varying(160)");
|
||||
|
||||
b.Property<string>("TargetRepoName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<string>("TargetRepositoryFullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)");
|
||||
|
||||
b.Property<string>("ThreadId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<string>("TurnId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("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<Guid>("Id")
|
||||
|
|
@ -93,6 +184,130 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.ToTable("AuditEvents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ActionKind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ActorKind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ActorName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(160)
|
||||
.HasColumnType("character varying(160)");
|
||||
|
||||
b.Property<Guid?>("ActorUserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AuthorityReference")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ExternalReceiptId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)");
|
||||
|
||||
b.Property<string>("FailureReason")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("MotionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PolicyDecision")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("ReceiptPayload")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ReceiptUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("RequestedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("SourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)");
|
||||
|
||||
b.Property<string>("SourceKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TargetLocator")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("TargetRepositoryFullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)");
|
||||
|
||||
b.Property<string>("TargetSurface")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("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<Guid>("Id")
|
||||
|
|
@ -497,9 +712,6 @@ namespace Bifrost.Web.Data.Migrations
|
|||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
|
@ -519,6 +731,9 @@ namespace Bifrost.Web.Data.Migrations
|
|||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("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");
|
||||
|
|
|
|||
|
|
@ -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<PatronSupportEvent> PatronSupportEvents { get; set; } = [];
|
||||
|
||||
public ICollection<BridgeAction> BridgeActions { get; set; } = [];
|
||||
|
||||
public ICollection<AgentDispatchRun> AgentDispatchRuns { get; set; } = [];
|
||||
|
||||
public ICollection<AuditEvent> 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();
|
||||
|
|
|
|||
196
src/Bifrost.Web/Features/Bridge/AgentDispatchRunService.cs
Normal file
196
src/Bifrost.Web/Features/Bridge/AgentDispatchRunService.cs
Normal file
|
|
@ -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<AgentDispatchRunResult> 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<AgentDispatchRunResult?> 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<AgentDispatchRunResult?> 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<AgentDispatchRunResult?> UpdateAsync(
|
||||
Guid runId,
|
||||
Guid? startedByUserAccountId,
|
||||
string auditAction,
|
||||
Func<AgentDispatchRun, string> 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);
|
||||
}
|
||||
337
src/Bifrost.Web/Features/Bridge/BridgeActionService.cs
Normal file
337
src/Bifrost.Web/Features/Bridge/BridgeActionService.cs
Normal file
|
|
@ -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<BridgeActionResult> 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<BridgeActionResult?> 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<BridgeActionResult?> 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<BridgeActionResult?> 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<BridgeActionResult?> UpdateAsync(
|
||||
Guid actionId,
|
||||
BridgeCaller caller,
|
||||
string auditAction,
|
||||
Func<BridgeAction, string> 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<BridgePolicyDecision> 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);
|
||||
|
|
@ -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<DashboardWorkItem> RecentWorkItems,
|
||||
IReadOnlyList<DashboardMotion> OpenMotionHighlights,
|
||||
IReadOnlyList<DashboardLaneItem> MyAssignedWork,
|
||||
IReadOnlyList<DashboardLaneItem> MyVolunteeredWork,
|
||||
IReadOnlyList<DashboardLaneItem> MySubmittedWork,
|
||||
IReadOnlyList<DashboardActivity> RecentActivity);
|
||||
IReadOnlyList<DashboardActivity> RecentActivity,
|
||||
IReadOnlyList<DashboardBridgeAction> RecentBridgeActions,
|
||||
IReadOnlyList<DashboardDispatchRun> 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);
|
||||
|
|
|
|||
|
|
@ -191,6 +191,88 @@
|
|||
}
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Agent runs</h2>
|
||||
<p class="muted">Recent dispatched Codex runs recorded by Bifrost.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.Snapshot.RecentDispatchRuns.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No dispatched agent runs have been recorded yet.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-grid">
|
||||
@foreach (var run in Model.Snapshot.RecentDispatchRuns)
|
||||
{
|
||||
<div class="status-banner">
|
||||
<strong>@run.TargetRepoName@if (!string.IsNullOrWhiteSpace(run.TargetAgentIdentity)) {<text> / @run.TargetAgentIdentity</text>}</strong>
|
||||
<span class="muted">@run.LaunchMode dispatch</span>
|
||||
<div class="inline-actions">
|
||||
<span class="pill">@run.Status</span>
|
||||
<span class="pill">@run.RequestId</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(run.ThreadId) || !string.IsNullOrWhiteSpace(run.TurnId))
|
||||
{
|
||||
<div class="muted">Thread @run.ThreadId Turn @run.TurnId</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(run.Note))
|
||||
{
|
||||
<div class="muted">@run.Note</div>
|
||||
}
|
||||
<div class="muted">@run.UpdatedAtUtc.ToString("dd MMM yyyy HH:mm") UTC</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel-grid" style="margin-top: 1.5rem;">
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Bridge receipts</h2>
|
||||
<p class="muted">Recent agent and service crossings recorded by Bifrost.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Model.Snapshot.RecentBridgeActions.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No bridge actions have been recorded yet.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-grid">
|
||||
@foreach (var action in Model.Snapshot.RecentBridgeActions)
|
||||
{
|
||||
<div class="status-banner">
|
||||
<strong>@action.ActorName</strong>
|
||||
<span class="muted">@action.ActionKind on @action.TargetSurface</span>
|
||||
<div class="inline-actions">
|
||||
<span class="pill">@action.Status</span>
|
||||
@if (!string.IsNullOrWhiteSpace(action.TargetRepositoryFullName))
|
||||
{
|
||||
<span class="pill">@action.TargetRepositoryFullName</span>
|
||||
}
|
||||
</div>
|
||||
<div class="muted">@action.Title</div>
|
||||
@if (!string.IsNullOrWhiteSpace(action.ReceiptLabel))
|
||||
{
|
||||
<div class="muted">@action.ReceiptLabel</div>
|
||||
}
|
||||
<div class="muted">@action.UpdatedAtUtc.ToString("dd MMM yyyy HH:mm") UTC</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel-grid" style="margin-top: 1.5rem;">
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@
|
|||
<span>Assigned to me</span>
|
||||
<strong>@Model.AssignedToCurrentMember</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>Bridge actions live</span>
|
||||
<strong>@Model.ActiveBridgeActions</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>Dispatch runs live</span>
|
||||
<strong>@Model.ActiveDispatchRuns</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>Approved nominal payout</span>
|
||||
<strong>@Model.ApprovedNominalPayoutValue.ToString("0.##")</strong>
|
||||
|
|
|
|||
|
|
@ -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<HeimdallOptions>()
|
||||
.BindConfiguration(HeimdallOptions.SectionName);
|
||||
|
||||
builder.Services
|
||||
.AddOptions<BridgeOptions>()
|
||||
.BindConfiguration(BridgeOptions.SectionName);
|
||||
|
||||
builder.Services.AddRazorPages(options =>
|
||||
{
|
||||
options.Conventions.AuthorizePage("/Membership/Status");
|
||||
|
|
@ -73,6 +79,8 @@ builder.Services.AddSingleton(TimeProvider.System);
|
|||
builder.Services.AddScoped<ICurrentBifrostActorAccessor, CurrentBifrostActorAccessor>();
|
||||
builder.Services.AddScoped<AuditTrailService>();
|
||||
builder.Services.AddScoped<DashboardSnapshotService>();
|
||||
builder.Services.AddScoped<BridgeActionService>();
|
||||
builder.Services.AddScoped<AgentDispatchRunService>();
|
||||
builder.Services.AddScoped<MembershipSynchronizationService>();
|
||||
builder.Services.AddScoped<GitHubWebhookService>();
|
||||
builder.Services.AddScoped<MotionGovernanceService>();
|
||||
|
|
@ -164,7 +172,7 @@ builder.Services.AddAuthorization(options =>
|
|||
});
|
||||
|
||||
var app = builder.Build();
|
||||
var eveJsonSerializerOptions = CreateEveJsonSerializerOptions();
|
||||
var appJsonSerializerOptions = CreateAppJsonSerializerOptions();
|
||||
|
||||
app.Services.GetRequiredService<StartupConfigurationValidator>().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> 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> 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> 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> 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> 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> 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> 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> 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>(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;
|
||||
|
|
|
|||
81
tests/Bifrost.Web.Tests/BridgeCliTests.cs
Normal file
81
tests/Bifrost.Web.Tests/BridgeCliTests.cs
Normal file
|
|
@ -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<ProcessResult> 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);
|
||||
}
|
||||
|
|
@ -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<TestWebApplicationFactory
|
|||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
Assert.Contains("/auth/sign-in", response.Headers.Location?.OriginalString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Member_console_shows_recent_bridge_receipts()
|
||||
{
|
||||
using (var warmupClient = _factory.CreateClient())
|
||||
{
|
||||
_ = await warmupClient.GetAsync("/App");
|
||||
}
|
||||
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TestWebApplicationFac
|
|||
Assert.Contains(verificationContext.AuditEvents, x => 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<BridgeActionHttpResult>();
|
||||
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<BifrostDbContext>();
|
||||
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<BridgeActionHttpResult>();
|
||||
Assert.NotNull(action);
|
||||
Assert.Equal("Denied", action.Status);
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
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<AgentDispatchRunHttpResult>();
|
||||
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<BifrostDbContext>();
|
||||
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<AgentDispatchRunHttpResult>();
|
||||
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<BifrostDbContext>();
|
||||
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<TestWebApplicationFac
|
|||
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
return request;
|
||||
}
|
||||
|
||||
private sealed class BridgeActionHttpResult
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
|
||||
public string Status { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class AgentDispatchRunHttpResult
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
|
||||
public string Status { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
|
|||
["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",
|
||||
|
|
|
|||
|
|
@ -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"] : []),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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"] : []),
|
||||
|
|
|
|||
Loading…
Reference in a new issue