diff --git a/.changeset/green-crabs-arrive.md b/.changeset/green-crabs-arrive.md new file mode 100644 index 000000000..ab7bce0c4 --- /dev/null +++ b/.changeset/green-crabs-arrive.md @@ -0,0 +1,5 @@ +--- +"agentweaver": minor +--- + +Platform admins can now connect a single platform-wide GitHub Copilot account for GitHub Copilot mode, separate from per-project Copilot connections. Agentweaver now blocks app access behind a setup screen until either BYOK or the platform-default Copilot connection is configured. diff --git a/apps/Agentweaver.Api.Data/Memory/GitHubConnectionsPersistenceRecords.cs b/apps/Agentweaver.Api.Data/Memory/GitHubConnectionsPersistenceRecords.cs index ce46ded3d..cacbdc89a 100644 --- a/apps/Agentweaver.Api.Data/Memory/GitHubConnectionsPersistenceRecords.cs +++ b/apps/Agentweaver.Api.Data/Memory/GitHubConnectionsPersistenceRecords.cs @@ -1,7 +1,7 @@ namespace Agentweaver.Api.Memory; public enum GitHubAppKind { Repo, Copilot } -public enum GitHubAuthorizationPurpose { InteractiveRepository, InteractiveCopilot, UnattendedRepository, UnattendedCopilot } +public enum GitHubAuthorizationPurpose { InteractiveRepository, InteractiveCopilot, UnattendedRepository, UnattendedCopilot, PlatformDefaultCopilot } public enum GitHubCapabilityPurpose { InteractiveRepository, InteractiveCopilot, UnattendedRepository, UnattendedCopilot } public enum GitHubCapabilitySnapshotSourceKind { UserAuthorization, RepositoryGrant, CopilotBinding } public enum GitHubAuthorizationStatus { Pending, Redeeming, Completed, Failed, Expired } @@ -141,6 +141,21 @@ public sealed class ProjectCopilotBindingRecord public DateTimeOffset? DeactivatedAt { get; set; } } +public sealed class PlatformDefaultCopilotBindingRecord +{ + public const string SingletonId = "platform-default"; + + public string Id { get; set; } = SingletonId; + public string EntraObjectId { get; set; } = ""; + public string CredentialReference { get; set; } = ""; + /// Stable identity of the authorization grant, not an access-token version. + public string CredentialVersion { get; set; } = ""; + public string GrantDigest { get; set; } = ""; + public GitHubBindingStatus Status { get; set; } + public DateTimeOffset BoundAt { get; set; } + public DateTimeOffset? DeactivatedAt { get; set; } +} + public sealed class AutomationActivationRecord { public string Id { get; set; } = ""; diff --git a/apps/Agentweaver.Api.Data/Memory/MemoryDbContext.cs b/apps/Agentweaver.Api.Data/Memory/MemoryDbContext.cs index e69019bdc..348d8eb1c 100644 --- a/apps/Agentweaver.Api.Data/Memory/MemoryDbContext.cs +++ b/apps/Agentweaver.Api.Data/Memory/MemoryDbContext.cs @@ -32,6 +32,7 @@ public sealed class MemoryDbContext(DbContextOptions options) : public DbSet GitHubRepositoryGrants => Set(); public DbSet GitHubRepositorySelectionCodes => Set(); public DbSet ProjectCopilotBindings => Set(); + public DbSet PlatformDefaultCopilotBindings => Set(); public DbSet AutomationActivations => Set(); public DbSet AutomationInvocations => Set(); public DbSet GitHubLifecycleDeliveries => Set(); @@ -630,6 +631,19 @@ private void ConfigureGitHubConnectionsPersistence(ModelBuilder model) ConfigureProjectForeignKey(e, "FK_project_copilot_bindings_projects_project_id"); }); + model.Entity(e => + { + e.ToTable("platform_default_copilot_bindings").HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.EntraObjectId).HasColumnName("entra_object_id"); + e.Property(x => x.CredentialReference).HasColumnName("credential_reference"); + e.Property(x => x.CredentialVersion).HasColumnName("credential_version"); + e.Property(x => x.GrantDigest).HasColumnName("grant_digest"); + e.Property(x => x.Status).HasColumnName("status"); + e.Property(x => x.BoundAt).HasColumnName("bound_at"); + e.Property(x => x.DeactivatedAt).HasColumnName("deactivated_at"); + }); + model.Entity(e => { e.ToTable("automation_activations").HasKey(x => x.Id); diff --git a/apps/Agentweaver.Api.Migrations.Postgres/Migrations/20260831164755_AddPlatformDefaultCopilotBinding.Designer.cs b/apps/Agentweaver.Api.Migrations.Postgres/Migrations/20260831164755_AddPlatformDefaultCopilotBinding.Designer.cs new file mode 100644 index 000000000..243d13d7b --- /dev/null +++ b/apps/Agentweaver.Api.Migrations.Postgres/Migrations/20260831164755_AddPlatformDefaultCopilotBinding.Designer.cs @@ -0,0 +1,2899 @@ +// +using System; +using Agentweaver.Api.Memory; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Agentweaver.Api.Migrations.Postgres.Migrations +{ + [DbContext(typeof(MemoryDbContext))] + [Migration("20260831164755_AddPlatformDefaultCopilotBinding")] + partial class AddPlatformDefaultCopilotBinding + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Agentweaver.Api.Auth.OAuth.BrowserEntraSession", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("EntraObjectId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("BrowserEntraSessions"); + }); + + modelBuilder.Entity("Agentweaver.Api.Auth.OAuth.EntraOAuthState", b => + { + b.Property("State") + .HasColumnType("text"); + + b.Property("CodeVerifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("State"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("EntraOAuthStates"); + }); + + modelBuilder.Entity("Agentweaver.Api.Auth.OAuth.WebSessionExchangeCode", b => + { + b.Property("Code") + .HasColumnType("text"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Login") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Code"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("WebSessionExchangeCodes"); + }); + + modelBuilder.Entity("Agentweaver.Api.Coordinator.CoordinatorAssemblyReviewRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AggregateTreeHash") + .HasColumnType("text"); + + b.Property("CoordinatorFailedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CoordinatorFailureReason") + .HasColumnType("text"); + + b.Property("CoordinatorRunId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecisionJson") + .HasColumnType("text"); + + b.Property("DecisionSubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IntegrationBranch") + .HasColumnType("text"); + + b.Property("OwnerUser") + .HasColumnType("text"); + + b.Property("Reviewer") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CoordinatorRunId") + .IsUnique(); + + b.ToTable("AssemblyReviews"); + }); + + modelBuilder.Entity("Agentweaver.Api.Diagnostics.HeartbeatStatusRecord", b => + { + b.Property("PodName") + .HasColumnType("text"); + + b.Property("ActedCount") + .HasColumnType("integer"); + + b.Property("DurationMs") + .HasColumnType("bigint"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("ErrorCount") + .HasColumnType("integer"); + + b.Property("IntervalSeconds") + .HasColumnType("integer"); + + b.Property("LastTickUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("PodName"); + + b.ToTable("HeartbeatStatuses"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.AgentMemory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApprovedBy") + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Importance") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SessionId") + .HasColumnType("text"); + + b.Property("SourceIdentity") + .HasColumnType("text"); + + b.Property("SourceKind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("legacy"); + + b.Property("SourceRunId") + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("text"); + + b.Property("TrustState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("legacy"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "AgentName"); + + b.HasIndex("ProjectId", "Type"); + + b.ToTable("AgentMemory"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.AutomationActivationRecord", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("AutomationKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("automation_key"); + + b.Property("CopilotBindingGrantDigest") + .HasColumnType("text") + .HasColumnName("copilot_binding_grant_digest"); + + b.Property("CopilotBindingId") + .HasColumnType("text") + .HasColumnName("copilot_binding_id"); + + b.Property("InstallationId") + .HasColumnType("bigint") + .HasColumnName("installation_id"); + + b.Property("InvalidatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("invalidated_at"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("RepositoryGrantDigest") + .HasColumnType("text") + .HasColumnName("repository_grant_digest"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId") + .IsUnique() + .HasDatabaseName("UX_automation_activations_active_project") + .HasFilter("status = 0"); + + b.HasIndex("InstallationId", "RepositoryId"); + + b.ToTable("automation_activations", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.AutomationInvocationRecord", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("ActivationId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("activation_id"); + + b.Property("BacklogTaskId") + .HasColumnType("text") + .HasColumnName("backlog_task_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("DeliveryId") + .HasColumnType("text") + .HasColumnName("delivery_id"); + + b.Property("EventName") + .HasColumnType("text") + .HasColumnName("event_name"); + + b.Property("InstallationId") + .HasColumnType("bigint") + .HasColumnName("installation_id"); + + b.Property("OccurrenceKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("occurrence_key"); + + b.Property("Outcome") + .HasColumnType("integer") + .HasColumnName("outcome"); + + b.Property("PendingBacklogTaskId") + .HasColumnType("text") + .HasColumnName("pending_backlog_task_id"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.HasKey("Id"); + + b.HasIndex("BacklogTaskId") + .IsUnique() + .HasDatabaseName("UX_automation_invocations_backlog_task_id") + .HasFilter("backlog_task_id IS NOT NULL"); + + b.HasIndex("DeliveryId") + .IsUnique() + .HasDatabaseName("UX_automation_invocations_delivery_id") + .HasFilter("delivery_id IS NOT NULL"); + + b.HasIndex("PendingBacklogTaskId") + .IsUnique() + .HasDatabaseName("UX_automation_invocations_pending_backlog_task_id") + .HasFilter("pending_backlog_task_id IS NOT NULL"); + + b.HasIndex("ProjectId"); + + b.HasIndex("ActivationId", "OccurrenceKey") + .IsUnique(); + + b.ToTable("automation_invocations", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.BacklogTaskDependencyRecord", b => + { + b.Property("TaskId") + .HasColumnType("text") + .HasColumnName("task_id"); + + b.Property("DependsOnTaskId") + .HasColumnType("text") + .HasColumnName("depends_on_task_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("project_id"); + + b.HasKey("TaskId", "DependsOnTaskId"); + + b.HasIndex("DependsOnTaskId") + .HasDatabaseName("IX_backlog_task_dependencies_prerequisite"); + + b.HasIndex("ProjectId", "TaskId") + .HasDatabaseName("IX_backlog_task_dependencies_project_task"); + + b.ToTable("backlog_task_dependencies", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.BacklogTaskRecord", b => + { + b.Property("TaskId") + .HasColumnType("text") + .HasColumnName("task_id"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("CapturedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("captured_by"); + + b.Property("CapturedByUserId") + .HasColumnType("text") + .HasColumnName("captured_by_user_id"); + + b.Property("ClaimedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_at"); + + b.Property("CommittedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("committed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsAutomationInvocationPending") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("automation_invocation_pending"); + + b.Property("OrderKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("order_key"); + + b.Property("ParentPrdRunId") + .HasColumnType("text") + .HasColumnName("parent_prd_run_id"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("PromotionKey") + .HasColumnType("text") + .HasColumnName("promotion_key"); + + b.Property("PromotionReason") + .HasColumnType("text") + .HasColumnName("promotion_reason"); + + b.Property("RunId") + .HasColumnType("text") + .HasColumnName("run_id"); + + b.Property("SourceFilePath") + .HasColumnType("text") + .HasColumnName("source_file_path"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("WorkflowOverrideId") + .HasColumnType("text") + .HasColumnName("workflow_override_id"); + + b.HasKey("TaskId"); + + b.HasIndex("RunId") + .IsUnique() + .HasDatabaseName("IX_backlog_tasks_run") + .HasFilter("run_id IS NOT NULL"); + + b.HasIndex("ParentPrdRunId", "PromotionKey") + .IsUnique() + .HasDatabaseName("IX_backlog_tasks_parent_promotion_key") + .HasFilter("parent_prd_run_id IS NOT NULL AND promotion_key IS NOT NULL"); + + b.HasIndex("ProjectId", "State", "OrderKey") + .IsUnique() + .HasDatabaseName("IX_backlog_tasks_orderkey_unique") + .HasFilter("state IN ('backlog','ready') AND archived_at IS NULL"); + + b.ToTable("backlog_tasks", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.BlueprintPackageAcquisitionRecord", b => + { + b.Property("OwnerId") + .HasColumnType("text") + .HasColumnName("owner_id"); + + b.Property("PackageId") + .HasColumnType("text") + .HasColumnName("package_id"); + + b.Property("CanonicalVersionKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("canonical_version_key"); + + b.Property("Ordinal") + .HasColumnType("integer") + .HasColumnName("ordinal"); + + b.Property("AcquiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acquired_at"); + + b.Property("CanonicalVersion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("canonical_version"); + + b.Property("Producer") + .HasColumnType("text") + .HasColumnName("producer"); + + b.Property("Repository") + .HasColumnType("text") + .HasColumnName("repository"); + + b.Property("RequestedRef") + .HasColumnType("text") + .HasColumnName("requested_ref"); + + b.Property("Revision") + .HasColumnType("text") + .HasColumnName("revision"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source"); + + b.HasKey("OwnerId", "PackageId", "CanonicalVersionKey", "Ordinal"); + + b.ToTable("blueprint_package_acquisitions", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.BlueprintPackageLibraryRecord", b => + { + b.Property("OwnerId") + .HasColumnType("text") + .HasColumnName("owner_id"); + + b.Property("PackageId") + .HasColumnType("text") + .HasColumnName("package_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.HasKey("OwnerId", "PackageId"); + + b.ToTable("blueprint_package_library", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.BlueprintPackagePayloadRecord", b => + { + b.Property("OwnerId") + .HasColumnType("text") + .HasColumnName("owner_id"); + + b.Property("PackageId") + .HasColumnType("text") + .HasColumnName("package_id"); + + b.Property("CanonicalVersionKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("canonical_version_key"); + + b.Property("Path") + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Bytes") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("bytes"); + + b.Property("CanonicalVersion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("canonical_version"); + + b.HasKey("OwnerId", "PackageId", "CanonicalVersionKey", "Path"); + + b.ToTable("blueprint_package_payloads", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.BlueprintPackageVersionRecord", b => + { + b.Property("OwnerId") + .HasColumnType("text") + .HasColumnName("owner_id"); + + b.Property("PackageId") + .HasColumnType("text") + .HasColumnName("package_id"); + + b.Property("CanonicalVersionKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("canonical_version_key"); + + b.Property("CanonicalVersion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("canonical_version"); + + b.Property("ContainerSha256") + .HasColumnType("text") + .HasColumnName("container_sha256"); + + b.Property("ContentDigest") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content_digest"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("PayloadSetDigest") + .IsRequired() + .HasColumnType("text") + .HasColumnName("payload_set_digest"); + + b.Property("RawManifest") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("raw_manifest"); + + b.Property("RawManifestSha256") + .IsRequired() + .HasColumnType("text") + .HasColumnName("raw_manifest_sha256"); + + b.HasKey("OwnerId", "PackageId", "CanonicalVersionKey"); + + b.ToTable("blueprint_package_versions", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.CastProposalRecord", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("ProposalJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("proposal_json"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId") + .HasDatabaseName("IX_cast_proposals_project_id"); + + b.ToTable("cast_proposals", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.Decision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApprovedBy") + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Rationale") + .HasColumnType("text"); + + b.Property("SourceIdentity") + .HasColumnType("text"); + + b.Property("SourceKind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("legacy"); + + b.Property("SourceRunId") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SupersededById") + .HasColumnType("integer"); + + b.Property("Tags") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("TrustState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("legacy"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SupersededById"); + + b.HasIndex("ProjectId", "AgentName"); + + b.HasIndex("ProjectId", "Status"); + + b.ToTable("Decisions"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.DecisionInboxEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecisionId") + .HasColumnType("integer"); + + b.Property("MergedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Rationale") + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceIdentity") + .HasColumnType("text"); + + b.Property("SourceKind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("legacy"); + + b.Property("SourceRunId") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DecisionId"); + + b.HasIndex("ProjectId", "Slug") + .IsUnique(); + + b.HasIndex("ProjectId", "Status"); + + b.ToTable("DecisionInbox"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.DismissedNotification", b => + { + b.Property("User") + .HasColumnType("text") + .HasColumnName("user"); + + b.Property("NotificationId") + .HasColumnType("text") + .HasColumnName("notification_id"); + + b.Property("DismissedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("dismissed_at"); + + b.HasKey("User", "NotificationId"); + + b.ToTable("dismissed_notifications", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubAppAuthorizationRecord", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("AppKind") + .HasColumnType("integer") + .HasColumnName("app_kind"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CredentialReference") + .IsRequired() + .HasColumnType("text") + .HasColumnName("credential_reference"); + + b.Property("CredentialVersion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("credential_version"); + + b.Property("EntraObjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("entra_object_id"); + + b.Property("GrantDigest") + .IsRequired() + .HasColumnType("text") + .HasColumnName("grant_digest"); + + b.Property("Purpose") + .HasColumnType("integer") + .HasColumnName("purpose"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.HasKey("Id"); + + b.HasIndex("EntraObjectId", "AppKind", "Purpose"); + + b.ToTable("github_app_authorizations", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("integer") + .HasColumnName("action"); + + b.Property("ActorKind") + .HasColumnType("integer") + .HasColumnName("actor_kind"); + + b.Property("AppKind") + .HasColumnType("integer") + .HasColumnName("app_kind"); + + b.Property("CapabilityPurpose") + .HasColumnType("integer") + .HasColumnName("capability_purpose"); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("correlation_id"); + + b.Property("EntraObjectId") + .HasColumnType("text") + .HasColumnName("entra_object_id"); + + b.Property("GrantDigest") + .HasColumnType("text") + .HasColumnName("grant_digest"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at"); + + b.Property("Outcome") + .HasColumnType("integer") + .HasColumnName("outcome"); + + b.Property("ReasonCode") + .HasColumnType("integer") + .HasColumnName("reason_code"); + + b.Property("ResourceId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("resource_id"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAt"); + + b.ToTable("github_audit_records", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubAuthorizationRecord", b => + { + b.Property("State") + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("AppKind") + .HasColumnType("integer") + .HasColumnName("app_kind"); + + b.Property("BrowserSessionId") + .HasColumnType("text") + .HasColumnName("browser_session_id"); + + b.Property("CallbackCookieHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("callback_cookie_hash"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EntraObjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("entra_object_id"); + + b.Property("ExpiresAtUnixMilliseconds") + .HasColumnType("bigint") + .HasColumnName("expires_at_unix_ms"); + + b.Property("ExternalTransactionId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("external_transaction_id"); + + b.Property("PkceVerifierProtected") + .IsRequired() + .HasColumnType("text") + .HasColumnName("pkce_verifier_protected"); + + b.Property("ProjectId") + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("Purpose") + .HasColumnType("integer") + .HasColumnName("purpose"); + + b.Property("ReturnRouteKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("return_route_key"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("State"); + + b.HasIndex("ExpiresAtUnixMilliseconds"); + + b.HasIndex("ExternalTransactionId") + .IsUnique(); + + b.HasIndex("ProjectId"); + + b.HasIndex("EntraObjectId", "State") + .IsUnique(); + + b.ToTable("github_authorizations", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubInstallationRecord", b => + { + b.Property("InstallationId") + .HasColumnType("bigint") + .HasColumnName("installation_id"); + + b.Property("AppKind") + .HasColumnType("integer") + .HasColumnName("app_kind"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ProjectId") + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.HasKey("InstallationId"); + + b.HasIndex("ProjectId"); + + b.ToTable("github_installations", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubLifecycleDeliveryRecord", b => + { + b.Property("DeliveryId") + .HasColumnType("text") + .HasColumnName("delivery_id"); + + b.Property("EventName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("event_name"); + + b.Property("InstallationId") + .HasColumnType("bigint") + .HasColumnName("installation_id"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.HasKey("DeliveryId"); + + b.ToTable("github_lifecycle_deliveries", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubRepositoryGrantRecord", b => + { + b.Property("InstallationId") + .HasColumnType("bigint") + .HasColumnName("installation_id"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.Property("FullNameDisplay") + .IsRequired() + .HasColumnType("text") + .HasColumnName("full_name_display"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("granted_at"); + + b.Property("PermissionDigest") + .IsRequired() + .HasColumnType("text") + .HasColumnName("permission_digest"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.HasKey("InstallationId", "RepositoryId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("InstallationId", "RepositoryId") + .IsUnique(); + + b.ToTable("github_repository_grants", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubRepositorySelectionCodeRecord", b => + { + b.Property("CodeHash") + .HasColumnType("text") + .HasColumnName("code_hash"); + + b.Property("ConsumedAtUnixMilliseconds") + .HasColumnType("bigint") + .HasColumnName("consumed_at_unix_ms"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EntraObjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("entra_object_id"); + + b.Property("ExpiresAtUnixMilliseconds") + .HasColumnType("bigint") + .HasColumnName("expires_at_unix_ms"); + + b.Property("RepoAppAuthorizationId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repo_app_authorization_id"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.HasKey("CodeHash"); + + b.HasIndex("ExpiresAtUnixMilliseconds"); + + b.HasIndex("EntraObjectId", "ExpiresAtUnixMilliseconds"); + + b.ToTable("github_repository_selection_codes", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.IntegrationBuildLockRecord", b => + { + b.Property("ProjectId") + .HasColumnType("text"); + + b.Property("AcquiredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OwnerPodId") + .IsRequired() + .HasColumnType("text"); + + b.Property("OwnerToken") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ProjectId"); + + b.ToTable("IntegrationBuildLocks"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.MarketplaceCopilotCapabilityRecord", b => + { + b.Property("CapabilityRef") + .HasColumnType("text") + .HasColumnName("capability_ref"); + + b.Property("ClaimLeaseExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("claim_lease_expires_at"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed_at"); + + b.Property("CredentialReference") + .IsRequired() + .HasColumnType("text") + .HasColumnName("credential_reference"); + + b.Property("CredentialVersion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("credential_version"); + + b.Property("EntraObjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("entra_object_id"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("GrantDigest") + .IsRequired() + .HasColumnType("text") + .HasColumnName("grant_digest"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("issued_at"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("Purpose") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("purpose"); + + b.Property("SourceBindingId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source_binding_id"); + + b.HasKey("CapabilityRef"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_marketplace_copilot_capabilities_expiry_cleanup"); + + b.HasIndex("ProjectId", "EntraObjectId", "ExpiresAt") + .HasDatabaseName("IX_marketplace_copilot_capabilities_expiry"); + + b.ToTable("marketplace_copilot_capabilities", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.OutcomeSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowTaskPromotion") + .HasColumnType("boolean"); + + b.Property("Assumptions") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClarifyingQuestions") + .HasColumnType("text"); + + b.Property("ConfirmedBy") + .HasColumnType("text"); + + b.Property("CoordinatorRunId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DesiredOutcome") + .IsRequired() + .HasColumnType("text"); + + b.Property("Goal") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "CoordinatorRunId"); + + b.ToTable("OutcomeSpecs"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.PlatformDefaultCopilotBindingRecord", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("BoundAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("bound_at"); + + b.Property("CredentialReference") + .IsRequired() + .HasColumnType("text") + .HasColumnName("credential_reference"); + + b.Property("CredentialVersion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("credential_version"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deactivated_at"); + + b.Property("EntraObjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("entra_object_id"); + + b.Property("GrantDigest") + .IsRequired() + .HasColumnType("text") + .HasColumnName("grant_digest"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("platform_default_copilot_bindings", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.ProjectCopilotBindingRecord", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("BoundAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("bound_at"); + + b.Property("CredentialReference") + .IsRequired() + .HasColumnType("text") + .HasColumnName("credential_reference"); + + b.Property("CredentialVersion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("credential_version"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deactivated_at"); + + b.Property("EntraObjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("entra_object_id"); + + b.Property("GrantDigest") + .IsRequired() + .HasColumnType("text") + .HasColumnName("grant_digest"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId") + .IsUnique() + .HasDatabaseName("UX_project_copilot_bindings_active_project") + .HasFilter("status = 0"); + + b.ToTable("project_copilot_bindings", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.ProjectRecord", b => + { + b.Property("ProjectId") + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("ActiveReviewPolicyName") + .HasColumnType("text") + .HasColumnName("active_review_policy_name"); + + b.Property("AllowedWorkflowIds") + .HasColumnType("text") + .HasColumnName("allowed_workflow_ids"); + + b.Property("BlueprintGenerationModel") + .HasColumnType("text") + .HasColumnName("blueprint_generation_model"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultBranch") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("main") + .HasColumnName("default_branch"); + + b.Property("DefaultModelCopilot") + .HasColumnType("text") + .HasColumnName("default_model_copilot"); + + b.Property("DefaultModelFoundry") + .HasColumnType("text") + .HasColumnName("default_model_foundry"); + + b.Property("DefaultProvider") + .IsRequired() + .HasColumnType("text") + .HasColumnName("default_provider"); + + b.Property("DefaultWorkflowId") + .HasColumnType("text") + .HasColumnName("default_workflow_id"); + + b.Property("MaxReadyPerHeartbeat") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3) + .HasColumnName("max_ready_per_heartbeat"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("OriginKind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("origin_kind"); + + b.Property("OutcomeSpecGenerationModel") + .HasColumnType("text") + .HasColumnName("outcome_spec_generation_model"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("text") + .HasColumnName("owner"); + + b.Property("PickupAutoApproveTools") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("pickup_auto_approve_tools"); + + b.Property("PickupAutopilot") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("pickup_autopilot"); + + b.Property("PreviewApprovalTimeoutMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(30) + .HasColumnName("preview_approval_timeout_minutes"); + + b.Property("SandboxProfile") + .HasColumnType("text") + .HasColumnName("sandbox_profile"); + + b.Property("SourceBlueprintId") + .HasColumnType("text") + .HasColumnName("source_blueprint_id"); + + b.Property("SourceBlueprintType") + .HasColumnType("text") + .HasColumnName("source_blueprint_type"); + + b.Property("SourceRepository") + .HasColumnType("text") + .HasColumnName("source_repository"); + + b.Property("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.Property("TeamRevision") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("team_revision"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WebhookSecret") + .HasColumnType("text") + .HasColumnName("webhook_secret"); + + b.Property("WorkflowGenerationModel") + .HasColumnType("text") + .HasColumnName("workflow_generation_model"); + + b.Property("WorkingDirectory") + .IsRequired() + .HasColumnType("text") + .HasColumnName("working_directory"); + + b.HasKey("ProjectId"); + + b.HasIndex("State") + .HasDatabaseName("IX_projects_state"); + + b.ToTable("projects", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.ProjectRoleAssignmentRecord", b => + { + b.Property("ProjectId") + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("PrincipalId") + .HasColumnType("text") + .HasColumnName("principal_id"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("granted_at"); + + b.Property("GrantedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("granted_by"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role"); + + b.HasKey("ProjectId", "PrincipalId"); + + b.HasIndex("PrincipalId") + .HasDatabaseName("IX_project_role_assignments_principal_id"); + + b.HasIndex("ProjectId", "Role") + .HasDatabaseName("IX_project_role_assignments_project_role"); + + b.ToTable("project_role_assignments", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.RunAuthorshipCapability", b => + { + b.Property("RunId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("run_id"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("token_hash"); + + b.HasKey("RunId"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("run_authorship_capabilities", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.RunGitHubCapabilitySnapshotRecord", b => + { + b.Property("SnapshotRef") + .HasColumnType("text") + .HasColumnName("snapshot_ref"); + + b.Property("AppKind") + .HasColumnType("integer") + .HasColumnName("app_kind"); + + b.Property("CapturedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("captured_at"); + + b.Property("CredentialReference") + .HasColumnType("text") + .HasColumnName("credential_reference"); + + b.Property("CredentialVersion") + .HasColumnType("text") + .HasColumnName("credential_version"); + + b.Property("EntraObjectId") + .HasColumnType("text") + .HasColumnName("entra_object_id"); + + b.Property("GrantDigest") + .IsRequired() + .HasColumnType("text") + .HasColumnName("grant_digest"); + + b.Property("InstallationId") + .HasColumnType("bigint") + .HasColumnName("installation_id"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("Purpose") + .HasColumnType("integer") + .HasColumnName("purpose"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.Property("RunId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("run_id"); + + b.Property("SnapshotExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("snapshot_expires_at"); + + b.Property("SourceAuthorizationId") + .HasColumnType("text") + .HasColumnName("source_authorization_id"); + + b.Property("SourceBindingId") + .HasColumnType("text") + .HasColumnName("source_binding_id"); + + b.Property("SourceKind") + .HasColumnType("integer") + .HasColumnName("source_kind"); + + b.HasKey("SnapshotRef"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RunId", "Purpose") + .IsUnique() + .HasDatabaseName("UX_run_github_capability_snapshots_run_purpose"); + + b.ToTable("run_github_capability_snapshots", null, t => + { + t.HasCheckConstraint("CK_run_github_capability_snapshots_purpose_mapping", "(purpose = 0 AND app_kind = 0 AND source_kind = 0 AND entra_object_id IS NOT NULL AND source_authorization_id IS NOT NULL AND source_binding_id IS NULL AND installation_id IS NULL AND repository_id IS NOT NULL AND credential_reference IS NOT NULL AND credential_version IS NOT NULL)\nOR (purpose = 1 AND app_kind = 0 AND source_kind = 0 AND entra_object_id IS NOT NULL AND source_authorization_id IS NOT NULL AND source_binding_id IS NULL AND installation_id IS NULL AND repository_id IS NULL AND credential_reference IS NOT NULL AND credential_version IS NOT NULL)\nOR (purpose = 2 AND app_kind = 0 AND source_kind = 1 AND entra_object_id IS NULL AND source_authorization_id IS NULL AND source_binding_id IS NULL AND installation_id IS NOT NULL AND repository_id IS NOT NULL AND credential_reference IS NULL AND credential_version IS NULL)\nOR (purpose = 3 AND app_kind = 1 AND source_kind = 2 AND entra_object_id IS NULL AND source_authorization_id IS NULL AND source_binding_id IS NOT NULL AND installation_id IS NULL AND repository_id IS NULL AND credential_reference IS NOT NULL AND credential_version IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.RunGitHubIdentitySnapshotRecord", b => + { + b.Property("RunId") + .HasColumnType("text") + .HasColumnName("run_id"); + + b.Property("AppKind") + .HasColumnType("integer") + .HasColumnName("app_kind"); + + b.Property("CapturedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("captured_at"); + + b.Property("CredentialReference") + .IsRequired() + .HasColumnType("text") + .HasColumnName("credential_reference"); + + b.Property("CredentialVersion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("credential_version"); + + b.Property("EntraObjectId") + .HasColumnType("text") + .HasColumnName("entra_object_id"); + + b.Property("GrantDigest") + .IsRequired() + .HasColumnType("text") + .HasColumnName("grant_digest"); + + b.Property("InstallationId") + .HasColumnType("bigint") + .HasColumnName("installation_id"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("Purpose") + .HasColumnType("integer") + .HasColumnName("purpose"); + + b.Property("RepositoryId") + .HasColumnType("bigint") + .HasColumnName("repository_id"); + + b.HasKey("RunId"); + + b.HasIndex("ProjectId"); + + b.ToTable("run_github_identity_snapshots", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.RunRecord", b => + { + b.Property("RunId") + .HasColumnType("text") + .HasColumnName("run_id"); + + b.Property("AgentCharter") + .HasColumnType("text") + .HasColumnName("agent_charter"); + + b.Property("AgentName") + .HasColumnType("text") + .HasColumnName("agent_name"); + + b.Property("ApprovalGeneration") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("approval_generation"); + + b.Property("ArchivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("archived_at"); + + b.Property("Attempt") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("attempt"); + + b.Property("Diff") + .HasColumnType("text") + .HasColumnName("diff"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("FencingToken") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("fencing_token"); + + b.Property("HeartbeatAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("heartbeat_at"); + + b.Property("LeaseExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("lease_expires_at"); + + b.Property("MergeConflicts") + .HasColumnType("text") + .HasColumnName("merge_conflicts"); + + b.Property("MergedCommitHash") + .HasColumnType("text") + .HasColumnName("merged_commit_hash"); + + b.Property("ModelId") + .HasColumnType("text") + .HasColumnName("model_id"); + + b.Property("ModelSource") + .IsRequired() + .HasColumnType("text") + .HasColumnName("model_source"); + + b.Property("Origin") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("interactive") + .HasColumnName("origin"); + + b.Property("OriginatingBranch") + .IsRequired() + .HasColumnType("text") + .HasColumnName("originating_branch"); + + b.Property("OwnerId") + .HasColumnType("text") + .HasColumnName("owner_id"); + + b.Property("ParentRunId") + .HasColumnType("text") + .HasColumnName("parent_run_id"); + + b.Property("ProjectId") + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("RepositoryPath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repository_path"); + + b.Property("Result") + .HasColumnType("text") + .HasColumnName("result"); + + b.Property("RetriedFrom") + .HasColumnType("text") + .HasColumnName("retried_from"); + + b.Property("ReviewReadyAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("review_ready_at"); + + b.Property("ReviewedBy") + .HasColumnType("text") + .HasColumnName("reviewed_by"); + + b.Property("SandboxBackend") + .HasColumnType("text") + .HasColumnName("sandbox_backend"); + + b.Property("SandboxClaimName") + .HasColumnType("text") + .HasColumnName("sandbox_claim_name"); + + b.Property("SandboxNamespace") + .HasColumnType("text") + .HasColumnName("sandbox_namespace"); + + b.Property("SandboxPodName") + .HasColumnType("text") + .HasColumnName("sandbox_pod_name"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("SubmittingUser") + .IsRequired() + .HasColumnType("text") + .HasColumnName("submitting_user"); + + b.Property("SubtaskId") + .HasColumnType("text") + .HasColumnName("subtask_id"); + + b.Property("Task") + .IsRequired() + .HasColumnType("text") + .HasColumnName("task"); + + b.Property("TreeHash") + .HasColumnType("text") + .HasColumnName("tree_hash"); + + b.Property("WorkflowRunId") + .HasColumnType("text") + .HasColumnName("workflow_run_id"); + + b.Property("WorkflowSelectionReason") + .HasColumnType("text") + .HasColumnName("workflow_selection_reason"); + + b.Property("WorktreeBranch") + .HasColumnType("text") + .HasColumnName("worktree_branch"); + + b.Property("WorktreePath") + .HasColumnType("text") + .HasColumnName("worktree_path"); + + b.HasKey("RunId"); + + b.HasIndex("WorkflowRunId") + .HasDatabaseName("IX_runs_workflow_run_id"); + + b.HasIndex("Origin", "Status") + .HasDatabaseName("IX_runs_origin_status"); + + b.HasIndex("ParentRunId", "SubtaskId") + .HasDatabaseName("IX_runs_parent_subtask"); + + b.HasIndex("ProjectId", "Status") + .HasDatabaseName("IX_runs_project_status"); + + b.ToTable("runs", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.RunRevisionRecord", b => + { + b.Property("RunId") + .HasColumnType("text") + .HasColumnName("run_id"); + + b.Property("RevisionNumber") + .HasColumnType("integer") + .HasColumnName("revision_number"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("PreviousTreeHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("previous_tree_hash"); + + b.Property("RawComment") + .IsRequired() + .HasColumnType("text") + .HasColumnName("raw_comment"); + + b.Property("ReviewerUser") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reviewer_user"); + + b.Property("SanitizedComment") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sanitized_comment"); + + b.HasKey("RunId", "RevisionNumber"); + + b.ToTable("run_revisions", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.SessionContext", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActiveIssues") + .HasColumnType("text"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FocusArea") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SerializedState") + .HasColumnType("text"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "EndedAt"); + + b.HasIndex("ProjectId", "SessionId") + .IsUnique(); + + b.ToTable("SessionContexts"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.SkillAssignmentRecord", b => + { + b.Property("ProjectId") + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("SkillId") + .HasColumnType("text") + .HasColumnName("skill_id"); + + b.Property("AgentName") + .HasColumnType("text") + .HasColumnName("agent_name"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.HasKey("ProjectId", "SkillId", "AgentName"); + + b.HasIndex("ProjectId", "AgentName") + .HasDatabaseName("IX_skill_assignments_agent"); + + b.ToTable("skill_assignments", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.SkillMarketplaceSourceRecord", b => + { + b.Property("SourceId") + .HasColumnType("text") + .HasColumnName("source_id"); + + b.Property("Branch") + .HasColumnType("text") + .HasColumnName("branch"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("ParseStrategy") + .HasColumnType("text") + .HasColumnName("parse_strategy"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("Repository") + .IsRequired() + .HasColumnType("text") + .HasColumnName("repository"); + + b.Property("Subpath") + .HasColumnType("text") + .HasColumnName("subpath"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("SourceId"); + + b.HasIndex("ProjectId"); + + b.ToTable("skill_marketplace_sources", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.SkillRecord", b => + { + b.Property("SkillId") + .HasColumnType("text") + .HasColumnName("skill_id"); + + b.Property("ContentHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content_hash"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Instructions") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instructions"); + + b.Property("MarketplaceName") + .HasColumnType("text") + .HasColumnName("marketplace_name"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("Provenance") + .IsRequired() + .HasColumnType("text") + .HasColumnName("provenance"); + + b.Property("Resources") + .HasColumnType("text") + .HasColumnName("resources"); + + b.Property("SourceLocation") + .HasColumnType("text") + .HasColumnName("source_location"); + + b.Property("SourceRepository") + .HasColumnType("text") + .HasColumnName("source_repository"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("SkillId"); + + b.HasAlternateKey("ProjectId", "SkillId") + .HasName("AK_skills_project_id_skill_id"); + + b.ToTable("skills", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.SteeringDirective", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionAttempt") + .HasColumnType("integer"); + + b.Property("CoordinatorRunId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("DecidedAction") + .HasColumnType("text"); + + b.Property("ExecStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExecutionAttempts") + .HasColumnType("integer"); + + b.Property("Instruction") + .IsRequired() + .HasColumnType("text"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("RelayedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Severity") + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetChildRunId") + .HasColumnType("text"); + + b.Property("TargetScopeJson") + .HasColumnType("text"); + + b.Property("TreeHash") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CoordinatorRunId", "Status"); + + b.ToTable("SteeringDirectives"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.SteeringRevisionExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionAttempt") + .HasColumnType("integer"); + + b.Property("CheckpointWatermark") + .HasColumnType("integer"); + + b.Property("ConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectState") + .IsRequired() + .HasColumnType("text"); + + b.Property("RunId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SteeringDirectiveId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SteeringDirectiveId", "ActionAttempt", "RunId") + .IsUnique(); + + b.ToTable("SteeringRevisionExecutions"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.Subtask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentCharter") + .HasColumnType("text"); + + b.Property("AssignedAgent") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChildRunId") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeclaredOutputPathsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("InfrastructureRetryCount") + .HasColumnType("integer"); + + b.Property("InfrastructureRetryEligibleAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsolationStrategy") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastResetAttempt") + .HasColumnType("integer"); + + b.Property("LastResetDirectiveId") + .HasColumnType("integer"); + + b.Property("LockedOutAgents") + .HasColumnType("text"); + + b.Property("Phase") + .IsRequired() + .HasColumnType("text"); + + b.Property("PriorChildRunId") + .HasColumnType("text"); + + b.Property("RecoveryAttempts") + .HasColumnType("integer"); + + b.Property("RecoveryGuidance") + .HasColumnType("text"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("text"); + + b.Property("SelectedModelId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SteeringRetentionUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkPlanId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("WorkPlanId"); + + b.ToTable("Subtasks"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.SubtaskDependency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DependsOnSubtaskId") + .HasColumnType("integer"); + + b.Property("SubtaskId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DependsOnSubtaskId"); + + b.HasIndex("SubtaskId"); + + b.ToTable("SubtaskDependencies"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.WorkPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AssemblyStage") + .HasColumnType("text"); + + b.Property("AssemblyStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AssemblyStatusReason") + .HasColumnType("text"); + + b.Property("AssemblyTerminalStage") + .HasColumnType("text"); + + b.Property("CoordinatorPodId") + .HasColumnType("text"); + + b.Property("CoordinatorRunId") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HumanReviewRoundTrips") + .HasColumnType("integer"); + + b.Property("IntegrationBranch") + .HasColumnType("text"); + + b.Property("IsolationSummary") + .HasColumnType("text"); + + b.Property("OutcomeSpecId") + .HasColumnType("integer"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SteeringIterations") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkflowId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CoordinatorRunId"); + + b.HasIndex("OutcomeSpecId"); + + b.ToTable("WorkPlans"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.WorkflowCheckpointRecord", b => + { + b.Property("StoreName") + .HasColumnType("text") + .HasColumnName("store_name"); + + b.Property("SessionId") + .HasColumnType("text") + .HasColumnName("session_id"); + + b.Property("CheckpointId") + .HasColumnType("text") + .HasColumnName("checkpoint_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("HasParentMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("has_parent_metadata"); + + b.Property("ParentCheckpointId") + .HasColumnType("text") + .HasColumnName("parent_checkpoint_id"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("StoreName", "SessionId", "CheckpointId"); + + b.HasIndex("StoreName", "SessionId") + .HasDatabaseName("IX_workflow_checkpoints_store_session"); + + b.ToTable("workflow_checkpoints", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.WorkflowRunRecord", b => + { + b.Property("WorkflowRunId") + .HasColumnType("text") + .HasColumnName("workflow_run_id"); + + b.Property("OrchestrationWorktreePath") + .HasColumnType("text") + .HasColumnName("orchestration_worktree_path"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("project_id"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("SubmittingUser") + .IsRequired() + .HasColumnType("text") + .HasColumnName("submitting_user"); + + b.Property("Task") + .IsRequired() + .HasColumnType("text") + .HasColumnName("task"); + + b.HasKey("WorkflowRunId"); + + b.HasIndex("ProjectId") + .HasDatabaseName("IX_workflow_runs_project_id"); + + b.ToTable("workflow_runs", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Runs.CoordinatorDeferredDecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecisionJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("RunId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RunId") + .IsUnique(); + + b.ToTable("DeferredDecisions"); + }); + + modelBuilder.Entity("Agentweaver.Api.Runs.PendingRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OwnerUser") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("RunId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("RunId") + .IsUnique(); + + b.ToTable("PendingRequests"); + }); + + modelBuilder.Entity("Agentweaver.Api.Runs.RunEventRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("text"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("RunId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Sequence") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RunId"); + + b.HasIndex("RunId", "Sequence") + .IsUnique(); + + b.ToTable("RunEvents"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.AutomationActivationRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_automation_activations_projects_project_id"); + + b.HasOne("Agentweaver.Api.Memory.GitHubRepositoryGrantRecord", null) + .WithMany() + .HasForeignKey("InstallationId", "RepositoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_automation_activations_repository_grants_installation_id_repository_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.AutomationInvocationRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.AutomationActivationRecord", null) + .WithMany() + .HasForeignKey("ActivationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_automation_invocations_activations_activation_id"); + + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_automation_invocations_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.Decision", b => + { + b.HasOne("Agentweaver.Api.Memory.Decision", null) + .WithMany() + .HasForeignKey("SupersededById"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.DecisionInboxEntry", b => + { + b.HasOne("Agentweaver.Api.Memory.Decision", null) + .WithMany() + .HasForeignKey("DecisionId"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubAuthorizationRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_github_authorizations_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubInstallationRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_github_installations_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubRepositoryGrantRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.GitHubInstallationRecord", null) + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_github_repository_grants_installations_installation_id"); + + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_github_repository_grants_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.MarketplaceCopilotCapabilityRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_marketplace_copilot_capabilities_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.ProjectCopilotBindingRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_project_copilot_bindings_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.RunGitHubCapabilitySnapshotRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_run_github_capability_snapshots_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.RunGitHubIdentitySnapshotRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_run_github_identity_snapshots_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.SkillAssignmentRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_skill_assignments_projects_project_id"); + + b.HasOne("Agentweaver.Api.Memory.SkillRecord", null) + .WithMany() + .HasForeignKey("ProjectId", "SkillId") + .HasPrincipalKey("ProjectId", "SkillId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_skill_assignments_skills_project_id_skill_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.SkillMarketplaceSourceRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_skill_marketplace_sources_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.SkillRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_skills_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.Subtask", b => + { + b.HasOne("Agentweaver.Api.Memory.WorkPlan", null) + .WithMany() + .HasForeignKey("WorkPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.SubtaskDependency", b => + { + b.HasOne("Agentweaver.Api.Memory.Subtask", null) + .WithMany() + .HasForeignKey("DependsOnSubtaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Agentweaver.Api.Memory.Subtask", null) + .WithMany() + .HasForeignKey("SubtaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.WorkPlan", b => + { + b.HasOne("Agentweaver.Api.Memory.OutcomeSpec", null) + .WithMany() + .HasForeignKey("OutcomeSpecId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/apps/Agentweaver.Api.Migrations.Postgres/Migrations/20260831164755_AddPlatformDefaultCopilotBinding.cs b/apps/Agentweaver.Api.Migrations.Postgres/Migrations/20260831164755_AddPlatformDefaultCopilotBinding.cs new file mode 100644 index 000000000..ae2c0374d --- /dev/null +++ b/apps/Agentweaver.Api.Migrations.Postgres/Migrations/20260831164755_AddPlatformDefaultCopilotBinding.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Agentweaver.Api.Migrations.Postgres.Migrations +{ + /// + public partial class AddPlatformDefaultCopilotBinding : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "platform_default_copilot_bindings", + columns: table => new + { + id = table.Column(type: "text", nullable: false), + entra_object_id = table.Column(type: "text", nullable: false), + credential_reference = table.Column(type: "text", nullable: false), + credential_version = table.Column(type: "text", nullable: false), + grant_digest = table.Column(type: "text", nullable: false), + status = table.Column(type: "integer", nullable: false), + bound_at = table.Column(type: "timestamp with time zone", nullable: false), + deactivated_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_platform_default_copilot_bindings", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "platform_default_copilot_bindings"); + } + } +} diff --git a/apps/Agentweaver.Api.Migrations.Postgres/Migrations/MemoryDbContextModelSnapshot.cs b/apps/Agentweaver.Api.Migrations.Postgres/Migrations/MemoryDbContextModelSnapshot.cs index d69e68739..62a76f992 100644 --- a/apps/Agentweaver.Api.Migrations.Postgres/Migrations/MemoryDbContextModelSnapshot.cs +++ b/apps/Agentweaver.Api.Migrations.Postgres/Migrations/MemoryDbContextModelSnapshot.cs @@ -1365,6 +1365,49 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("OutcomeSpecs"); }); + modelBuilder.Entity("Agentweaver.Api.Memory.PlatformDefaultCopilotBindingRecord", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("BoundAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("bound_at"); + + b.Property("CredentialReference") + .IsRequired() + .HasColumnType("text") + .HasColumnName("credential_reference"); + + b.Property("CredentialVersion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("credential_version"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deactivated_at"); + + b.Property("EntraObjectId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("entra_object_id"); + + b.Property("GrantDigest") + .IsRequired() + .HasColumnType("text") + .HasColumnName("grant_digest"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("platform_default_copilot_bindings", (string)null); + }); + modelBuilder.Entity("Agentweaver.Api.Memory.ProjectCopilotBindingRecord", b => { b.Property("Id") diff --git a/apps/Agentweaver.Api/Auth/AutomationInvocationService.cs b/apps/Agentweaver.Api/Auth/AutomationInvocationService.cs index 9f854cbe1..38984aa8f 100644 --- a/apps/Agentweaver.Api/Auth/AutomationInvocationService.cs +++ b/apps/Agentweaver.Api/Auth/AutomationInvocationService.cs @@ -399,12 +399,11 @@ public async Task TryPrepareRunAsync( activation.InstallationId != invocation.InstallationId || activation.RepositoryId != invocation.RepositoryId) return false; - var binding = await db.ProjectCopilotBindings.AsNoTracking().SingleOrDefaultAsync(x => - x.Id == activation.CopilotBindingId && - x.ProjectId == activation.ProjectId && - x.GrantDigest == activation.CopilotBindingGrantDigest && - x.Status == GitHubBindingStatus.Active && - x.DeactivatedAt == null, ct).ConfigureAwait(false); + var binding = await persistence.GetLiveAutomationCopilotBindingAsync( + activation.ProjectId, + activation.CopilotBindingId, + activation.CopilotBindingGrantDigest, + ct).ConfigureAwait(false); if (binding is null) return false; @@ -459,7 +458,7 @@ public async Task TryPrepareRunAsync( private static bool MatchesActivation( IReadOnlyList snapshots, FencedAutomationActivation activation, - ProjectCopilotBindingRecord binding) => + CopilotBindingSnapshotSource binding) => snapshots.Count == 2 && snapshots.Any(x => x.Purpose == GitHubCapabilityPurpose.UnattendedRepository && x.AppKind == GitHubAppKind.Repo && diff --git a/apps/Agentweaver.Api/Auth/GitHubConnectionsCredentialVault.cs b/apps/Agentweaver.Api/Auth/GitHubConnectionsCredentialVault.cs index 82c2d2464..4c3a2c6ed 100644 --- a/apps/Agentweaver.Api/Auth/GitHubConnectionsCredentialVault.cs +++ b/apps/Agentweaver.Api/Auth/GitHubConnectionsCredentialVault.cs @@ -1,76 +1,85 @@ -using System.Text.Json; - -namespace Agentweaver.Api.Auth; - -/// Opaque, vault-owned locator for reserved GitHub connections credential material. -internal sealed record GitHubConnectionsCredentialLocator -{ - private GitHubConnectionsCredentialLocator(string key) => Key = key; - - internal string Key { get; } - - internal static GitHubConnectionsCredentialLocator ForRepoAppUser(string credentialReference) => - Create(credentialReference, "repo-app-user-credential-"); - - internal static GitHubConnectionsCredentialLocator ForCopilotProject(string credentialReference) => - Create(credentialReference, "copilot-app-project-"); - - private static GitHubConnectionsCredentialLocator Create(string credentialReference, string requiredPrefix) - { - if (string.IsNullOrWhiteSpace(credentialReference) || - !credentialReference.StartsWith(requiredPrefix, StringComparison.Ordinal)) - throw new ArgumentException("Credential reference is not a reserved GitHub connections locator.", nameof(credentialReference)); - return new(credentialReference); - } -} - -internal interface IGitHubConnectionsCredentialVault -{ - Task ReadCurrentAsync(GitHubConnectionsCredentialLocator locator, CancellationToken ct = default); - Task WriteAsync(GitHubConnectionsCredentialLocator locator, string value, CancellationToken ct = default); - Task TombstoneAndDeleteAsync(GitHubConnectionsCredentialLocator locator, CancellationToken ct = default); -} - -/// -/// The sole GitHub connections authority allowed to bridge reserved credential locators to generic secret -/// storage. Reads are current-version only and tombstones cannot be treated as a credential. -/// -internal sealed class GitHubConnectionsCredentialVault(ISecretStore secretStore) : IGitHubConnectionsCredentialVault -{ - private const string Tombstone = """{"status":"revoked"}"""; - - public async Task ReadCurrentAsync(GitHubConnectionsCredentialLocator locator, CancellationToken ct = default) - { - var result = await secretStore.GetSecretAsync(locator.Key, ct).ConfigureAwait(false); - return !result.Found || IsTombstone(result.Value) ? SecretGetResult.NotFound : result; - } - - public async Task WriteAsync(GitHubConnectionsCredentialLocator locator, string value, CancellationToken ct = default) - { - if (string.IsNullOrWhiteSpace(value) || IsTombstone(value)) - throw new ArgumentException("The vault cannot write empty or tombstone credential material.", nameof(value)); - await secretStore.SetSecretAsync(locator.Key, value, ct: ct).ConfigureAwait(false); - } - - public async Task TombstoneAndDeleteAsync(GitHubConnectionsCredentialLocator locator, CancellationToken ct = default) - { - await secretStore.SetSecretAsync(locator.Key, Tombstone, ct: ct).ConfigureAwait(false); - await secretStore.DeleteSecretAsync(locator.Key, ct).ConfigureAwait(false); - } - - private static bool IsTombstone(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - return false; - try - { - using var document = JsonDocument.Parse(value); - return document.RootElement.TryGetProperty("status", out var status) && - string.Equals(status.GetString(), "revoked", StringComparison.Ordinal); - } - catch (JsonException) - { - return false; - } - } -} +using System.Text.Json; + +namespace Agentweaver.Api.Auth; + +/// Opaque, vault-owned locator for reserved GitHub connections credential material. +internal sealed record GitHubConnectionsCredentialLocator +{ + private GitHubConnectionsCredentialLocator(string key) => Key = key; + + internal string Key { get; } + + internal static GitHubConnectionsCredentialLocator ForRepoAppUser(string credentialReference) => + Create(credentialReference, "repo-app-user-credential-"); + + internal static GitHubConnectionsCredentialLocator ForCopilotProject(string credentialReference) => + Create(credentialReference, "copilot-app-project-"); + + internal static GitHubConnectionsCredentialLocator ForCopilotBinding(string credentialReference) + { + if (string.IsNullOrWhiteSpace(credentialReference) || + (!credentialReference.StartsWith("copilot-app-project-", StringComparison.Ordinal) && + !credentialReference.StartsWith("copilot-app-platform-default-", StringComparison.Ordinal))) + throw new ArgumentException("Credential reference is not a reserved GitHub connections locator.", nameof(credentialReference)); + return new(credentialReference); + } + + private static GitHubConnectionsCredentialLocator Create(string credentialReference, string requiredPrefix) + { + if (string.IsNullOrWhiteSpace(credentialReference) || + !credentialReference.StartsWith(requiredPrefix, StringComparison.Ordinal)) + throw new ArgumentException("Credential reference is not a reserved GitHub connections locator.", nameof(credentialReference)); + return new(credentialReference); + } +} + +internal interface IGitHubConnectionsCredentialVault +{ + Task ReadCurrentAsync(GitHubConnectionsCredentialLocator locator, CancellationToken ct = default); + Task WriteAsync(GitHubConnectionsCredentialLocator locator, string value, CancellationToken ct = default); + Task TombstoneAndDeleteAsync(GitHubConnectionsCredentialLocator locator, CancellationToken ct = default); +} + +/// +/// The sole GitHub connections authority allowed to bridge reserved credential locators to generic secret +/// storage. Reads are current-version only and tombstones cannot be treated as a credential. +/// +internal sealed class GitHubConnectionsCredentialVault(ISecretStore secretStore) : IGitHubConnectionsCredentialVault +{ + private const string Tombstone = """{"status":"revoked"}"""; + + public async Task ReadCurrentAsync(GitHubConnectionsCredentialLocator locator, CancellationToken ct = default) + { + var result = await secretStore.GetSecretAsync(locator.Key, ct).ConfigureAwait(false); + return !result.Found || IsTombstone(result.Value) ? SecretGetResult.NotFound : result; + } + + public async Task WriteAsync(GitHubConnectionsCredentialLocator locator, string value, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(value) || IsTombstone(value)) + throw new ArgumentException("The vault cannot write empty or tombstone credential material.", nameof(value)); + await secretStore.SetSecretAsync(locator.Key, value, ct: ct).ConfigureAwait(false); + } + + public async Task TombstoneAndDeleteAsync(GitHubConnectionsCredentialLocator locator, CancellationToken ct = default) + { + await secretStore.SetSecretAsync(locator.Key, Tombstone, ct: ct).ConfigureAwait(false); + await secretStore.DeleteSecretAsync(locator.Key, ct).ConfigureAwait(false); + } + + private static bool IsTombstone(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + try + { + using var document = JsonDocument.Parse(value); + return document.RootElement.TryGetProperty("status", out var status) && + string.Equals(status.GetString(), "revoked", StringComparison.Ordinal); + } + catch (JsonException) + { + return false; + } + } +} diff --git a/apps/Agentweaver.Api/Auth/GitHubConnectionsPersistenceStore.cs b/apps/Agentweaver.Api/Auth/GitHubConnectionsPersistenceStore.cs index 202e8e8fa..a76a90c3f 100644 --- a/apps/Agentweaver.Api/Auth/GitHubConnectionsPersistenceStore.cs +++ b/apps/Agentweaver.Api/Auth/GitHubConnectionsPersistenceStore.cs @@ -115,11 +115,20 @@ internal sealed record RepoAppAuthorizationTransaction( string CallbackCookieHash, string? BrowserSessionId); internal sealed record CopilotAuthorizationTransaction(string State, string EntraObjectId, string ProjectId, long ExpiresAtUnixMilliseconds, string ReturnRouteKey, string PkceVerifierProtected, string CallbackCookieHash, string? BrowserSessionId); +internal sealed record PlatformDefaultCopilotAuthorizationTransaction(string State, string EntraObjectId, long ExpiresAtUnixMilliseconds, string ReturnRouteKey, string PkceVerifierProtected, string CallbackCookieHash, string? BrowserSessionId); internal sealed record RepoAppCredentialReference( string Id, string CredentialReference, string CredentialVersion, DateTimeOffset CreatedAt); +internal sealed record PlatformDefaultCopilotAuthorizationCompletion( + bool Completed, + RepoAppCredentialReference? ReplacedCredential); +internal sealed record CopilotBindingSnapshotSource( + string Id, + string CredentialReference, + string CredentialVersion, + string GrantDigest); internal sealed record RepoAppAuthorizationCompletion( bool Completed, IReadOnlyList RevokedCredentials); @@ -341,6 +350,21 @@ internal Task IsLiveRepoAppCredentialAsync( internal Task GetCopilotAuthorizationTransactionByIdAsync(string id, string subject, CancellationToken ct = default) => db.GitHubAuthorizations.AsNoTracking().Where(x => x.ExternalTransactionId == id && x.EntraObjectId == subject && x.AppKind == GitHubAppKind.Copilot && x.Purpose == GitHubAuthorizationPurpose.InteractiveCopilot && x.ProjectId != null) .Select(x => new CopilotAuthorizationTransaction(x.State, x.EntraObjectId, x.ProjectId!, x.ExpiresAtUnixMilliseconds, x.ReturnRouteKey, x.PkceVerifierProtected, x.CallbackCookieHash, x.BrowserSessionId)).SingleOrDefaultAsync(ct); + internal Task GetPlatformDefaultCopilotAuthorizationTransactionAsync(string state, CancellationToken ct = default) => + db.GitHubAuthorizations.AsNoTracking() + .Where(x => x.State == state && + x.AppKind == GitHubAppKind.Copilot && + x.Purpose == GitHubAuthorizationPurpose.PlatformDefaultCopilot && + x.ProjectId == null) + .Select(x => new PlatformDefaultCopilotAuthorizationTransaction( + x.State, + x.EntraObjectId, + x.ExpiresAtUnixMilliseconds, + x.ReturnRouteKey, + x.PkceVerifierProtected, + x.CallbackCookieHash, + x.BrowserSessionId)) + .SingleOrDefaultAsync(ct); internal Task GetMcpBrowserHandoffTransactionAsync( string transactionId, GitHubAppKind appKind, @@ -709,6 +733,42 @@ await db.AutomationActivations } } + public async Task ReplacePlatformDefaultCopilotBindingAsync( + PlatformDefaultCopilotBindingRecord binding, + CancellationToken ct = default) + { + EnsureSafe(binding); + EnsurePlatformDefaultCopilotBinding(binding); + var existing = await db.PlatformDefaultCopilotBindings + .SingleOrDefaultAsync(x => x.Id == PlatformDefaultCopilotBindingRecord.SingletonId, ct) + .ConfigureAwait(false); + if (existing is null) + { + db.PlatformDefaultCopilotBindings.Add(binding); + } + else + { + existing.EntraObjectId = binding.EntraObjectId; + existing.CredentialReference = binding.CredentialReference; + existing.CredentialVersion = binding.CredentialVersion; + existing.GrantDigest = binding.GrantDigest; + existing.Status = binding.Status; + existing.BoundAt = binding.BoundAt; + existing.DeactivatedAt = binding.DeactivatedAt; + } + + try + { + await db.SaveChangesAsync(ct).ConfigureAwait(false); + return BindingWriteResult.Bound; + } + catch (DbUpdateException ex) when (IsUniqueViolation(ex)) + { + db.ChangeTracker.Clear(); + return BindingWriteResult.Unavailable; + } + } + internal async Task CompleteCopilotAuthorizationAsync(string state, ProjectCopilotBindingRecord binding, GitHubAuditRecord audit, CancellationToken ct = default) { EnsureSafe(binding); EnsureSafe(audit); @@ -729,6 +789,78 @@ await db.AutomationActivations.Where(x => x.ProjectId == binding.ProjectId && x. catch (DbUpdateException) { await tx.RollbackAsync(CancellationToken.None).ConfigureAwait(false); db.ChangeTracker.Clear(); return false; } } + internal async Task CompletePlatformDefaultCopilotAuthorizationAsync( + string state, + PlatformDefaultCopilotBindingRecord binding, + GitHubAuditRecord audit, + CancellationToken ct = default) + { + EnsureSafe(binding); + EnsurePlatformDefaultCopilotBinding(binding); + EnsureSafe(audit); + await using var tx = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct).ConfigureAwait(false); + var now = DateTimeOffset.UtcNow; + var existing = await db.PlatformDefaultCopilotBindings + .SingleOrDefaultAsync(x => x.Id == PlatformDefaultCopilotBindingRecord.SingletonId, ct) + .ConfigureAwait(false); + RepoAppCredentialReference? replacedCredential = null; + if (existing is null) + { + db.PlatformDefaultCopilotBindings.Add(binding); + } + else + { + if (existing.Status == GitHubBindingStatus.Active && + existing.DeactivatedAt is null && + (!string.Equals(existing.CredentialReference, binding.CredentialReference, StringComparison.Ordinal) || + !string.Equals(existing.CredentialVersion, binding.CredentialVersion, StringComparison.Ordinal))) + { + replacedCredential = new RepoAppCredentialReference( + existing.Id, + existing.CredentialReference, + existing.CredentialVersion, + existing.BoundAt); + } + existing.EntraObjectId = binding.EntraObjectId; + existing.CredentialReference = binding.CredentialReference; + existing.CredentialVersion = binding.CredentialVersion; + existing.GrantDigest = binding.GrantDigest; + existing.Status = binding.Status; + existing.BoundAt = binding.BoundAt; + existing.DeactivatedAt = binding.DeactivatedAt; + } + + await db.AutomationActivations + .Where(x => x.CopilotBindingId == PlatformDefaultCopilotBindingRecord.SingletonId && + x.Status == AutomationActivationStatus.Active) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.Status, AutomationActivationStatus.Invalidated) + .SetProperty(x => x.InvalidatedAt, now), ct) + .ConfigureAwait(false); + db.GitHubAuditRecords.Add(audit); + try + { + var claimed = await db.GitHubAuthorizations.Where(x => x.State == state && x.Status == GitHubAuthorizationStatus.Redeeming) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, GitHubAuthorizationStatus.Completed).SetProperty(x => x.CompletedAt, now), ct).ConfigureAwait(false); + if (claimed != 1) + { + await tx.RollbackAsync(CancellationToken.None).ConfigureAwait(false); + db.ChangeTracker.Clear(); + return new(false, null); + } + + await db.SaveChangesAsync(ct).ConfigureAwait(false); + await tx.CommitAsync(ct).ConfigureAwait(false); + return new(true, replacedCredential); + } + catch (DbUpdateException) + { + await tx.RollbackAsync(CancellationToken.None).ConfigureAwait(false); + db.ChangeTracker.Clear(); + return new(false, null); + } + } + internal async Task CompleteCopilotAuthorizationFailureAsync(string state, GitHubAuditRecord audit, CancellationToken ct = default) { EnsureSafe(audit); await using var tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false); @@ -764,6 +896,78 @@ await db.AutomationActivations.Where(x => x.ProjectId == projectId && x.Status = x.Id, x.CredentialReference, x.CredentialVersion, x.BoundAt)) .SingleOrDefaultAsync(ct); + internal async Task RevokePlatformDefaultCopilotBindingAsync( + GitHubAuditRecord audit, + CancellationToken ct = default) + { + EnsureSafe(audit); + await using var tx = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct).ConfigureAwait(false); + var binding = await db.PlatformDefaultCopilotBindings + .Where(x => x.Id == PlatformDefaultCopilotBindingRecord.SingletonId && + x.Status == GitHubBindingStatus.Active && + x.DeactivatedAt == null) + .Select(x => new RepoAppCredentialReference(x.Id, x.CredentialReference, x.CredentialVersion, x.BoundAt)) + .SingleOrDefaultAsync(ct) + .ConfigureAwait(false); + if (binding is null) + { + await tx.RollbackAsync(CancellationToken.None).ConfigureAwait(false); + return null; + } + + var now = DateTimeOffset.UtcNow; + db.GitHubAuditRecords.Add(audit); + await db.PlatformDefaultCopilotBindings + .Where(x => x.Id == binding.Id && x.Status == GitHubBindingStatus.Active) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.Status, GitHubBindingStatus.Revoked) + .SetProperty(x => x.DeactivatedAt, now), ct) + .ConfigureAwait(false); + await db.AutomationActivations + .Where(x => x.CopilotBindingId == PlatformDefaultCopilotBindingRecord.SingletonId && + x.Status == AutomationActivationStatus.Active) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.Status, AutomationActivationStatus.Invalidated) + .SetProperty(x => x.InvalidatedAt, now), ct) + .ConfigureAwait(false); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + await tx.CommitAsync(ct).ConfigureAwait(false); + return binding; + } + + internal Task GetActivePlatformDefaultCopilotBindingAsync( + CancellationToken ct = default) => + db.PlatformDefaultCopilotBindings.AsNoTracking() + .Where(x => x.Id == PlatformDefaultCopilotBindingRecord.SingletonId && + x.Status == GitHubBindingStatus.Active && + x.DeactivatedAt == null) + .Select(x => new RepoAppCredentialReference( + x.Id, x.CredentialReference, x.CredentialVersion, x.BoundAt)) + .SingleOrDefaultAsync(ct); + + internal async Task> ListActiveCopilotBindingsAsync( + string? excludeBindingId, + CancellationToken ct = default) + { + var projectBindings = await db.ProjectCopilotBindings.AsNoTracking() + .Where(x => x.Status == GitHubBindingStatus.Active && + x.DeactivatedAt == null && + x.Id != excludeBindingId) + .Select(x => new RepoAppCredentialReference( + x.Id, x.CredentialReference, x.CredentialVersion, x.BoundAt)) + .ToListAsync(ct) + .ConfigureAwait(false); + var platformBindings = await db.PlatformDefaultCopilotBindings.AsNoTracking() + .Where(x => x.Status == GitHubBindingStatus.Active && + x.DeactivatedAt == null && + x.Id != excludeBindingId) + .Select(x => new RepoAppCredentialReference( + x.Id, x.CredentialReference, x.CredentialVersion, x.BoundAt)) + .ToListAsync(ct) + .ConfigureAwait(false); + return projectBindings.Concat(platformBindings).ToArray(); + } + /// /// Resolves exactly one current, project-bound Repo App grant and Copilot binding, then /// atomically records their immutable identity tuple. Callers cannot supply any repository, @@ -789,12 +993,20 @@ await db.AutomationActivations.Where(x => x.ProjectId == projectId && x.Status = installation.RevokedAt == null)) .Select(grant => new { grant.InstallationId, grant.RepositoryId, grant.PermissionDigest }) .ToListAsync(ct).ConfigureAwait(false); - var bindings = await db.ProjectCopilotBindings.AsNoTracking() + var projectBindings = await db.ProjectCopilotBindings.AsNoTracking() .Where(binding => binding.ProjectId == projectId && binding.Status == GitHubBindingStatus.Active && binding.DeactivatedAt == null) .Select(binding => new { binding.Id, binding.GrantDigest }) .ToListAsync(ct).ConfigureAwait(false); + var bindings = projectBindings.Count > 0 + ? projectBindings + : await db.PlatformDefaultCopilotBindings.AsNoTracking() + .Where(binding => binding.Id == PlatformDefaultCopilotBindingRecord.SingletonId && + binding.Status == GitHubBindingStatus.Active && + binding.DeactivatedAt == null) + .Select(binding => new { binding.Id, binding.GrantDigest }) + .ToListAsync(ct).ConfigureAwait(false); var result = grants.Count switch { @@ -880,13 +1092,12 @@ await AppendAuditAsync(CreateActivationAudit( installation.InstallationId == activation.InstallationId && installation.AppKind == GitHubAppKind.Repo && installation.ProjectId == activation.ProjectId && - installation.RevokedAt == null) && - db.ProjectCopilotBindings.Any(binding => - binding.Id == activation.CopilotBindingId && - binding.ProjectId == activation.ProjectId && - binding.GrantDigest == activation.CopilotBindingGrantDigest && - binding.Status == GitHubBindingStatus.Active && - binding.DeactivatedAt == null), ct).ConfigureAwait(false); + installation.RevokedAt == null), ct).ConfigureAwait(false) && + await IsLiveCopilotBindingAsync( + activation.ProjectId, + activation.CopilotBindingId, + activation.CopilotBindingGrantDigest, + ct).ConfigureAwait(false); return !isLive ? null : new( activation.Id, activation.ProjectId, activation.InstallationId, activation.RepositoryId, @@ -1023,14 +1234,25 @@ public async Task TryInsertCapabilitySnapshotAsync( installation.AppKind == GitHubAppKind.Repo && installation.ProjectId == snapshot.ProjectId && installation.RevokedAt == null), ct).ConfigureAwait(false), - GitHubCapabilitySnapshotSourceKind.CopilotBinding => await db.ProjectCopilotBindings.AsNoTracking() - .AnyAsync(x => x.Id == snapshot.SourceBindingId && - x.ProjectId == snapshot.ProjectId && - x.CredentialReference == snapshot.CredentialReference && - x.CredentialVersion == snapshot.CredentialVersion && - x.GrantDigest == snapshot.GrantDigest && - x.Status == GitHubBindingStatus.Active && - x.DeactivatedAt == null, ct).ConfigureAwait(false), + GitHubCapabilitySnapshotSourceKind.CopilotBinding => string.Equals( + snapshot.SourceBindingId, + PlatformDefaultCopilotBindingRecord.SingletonId, + StringComparison.Ordinal) + ? await db.PlatformDefaultCopilotBindings.AsNoTracking() + .AnyAsync(x => x.Id == snapshot.SourceBindingId && + x.CredentialReference == snapshot.CredentialReference && + x.CredentialVersion == snapshot.CredentialVersion && + x.GrantDigest == snapshot.GrantDigest && + x.Status == GitHubBindingStatus.Active && + x.DeactivatedAt == null, ct).ConfigureAwait(false) + : await db.ProjectCopilotBindings.AsNoTracking() + .AnyAsync(x => x.Id == snapshot.SourceBindingId && + x.ProjectId == snapshot.ProjectId && + x.CredentialReference == snapshot.CredentialReference && + x.CredentialVersion == snapshot.CredentialVersion && + x.GrantDigest == snapshot.GrantDigest && + x.Status == GitHubBindingStatus.Active && + x.DeactivatedAt == null, ct).ConfigureAwait(false), _ => false, }; return !isLive @@ -1043,7 +1265,7 @@ public async Task TryInsertCapabilitySnapshotAsync( GitHubCapabilitySnapshotSourceKind.UserAuthorization => GitHubConnectionsCredentialLocator.ForRepoAppUser(snapshot.CredentialReference!), GitHubCapabilitySnapshotSourceKind.CopilotBinding => - GitHubConnectionsCredentialLocator.ForCopilotProject(snapshot.CredentialReference!), + GitHubConnectionsCredentialLocator.ForCopilotBinding(snapshot.CredentialReference!), _ => null, }, }; @@ -1580,9 +1802,7 @@ public async Task CaptureRootCapabilitySnapsho string projectId, CancellationToken ct) { - var binding = await db.ProjectCopilotBindings.AsNoTracking().SingleOrDefaultAsync(x => - x.ProjectId == projectId && x.Status == GitHubBindingStatus.Active && x.DeactivatedAt == null, ct) - .ConfigureAwait(false); + var binding = await GetActiveCopilotBindingOrPlatformDefaultAsync(projectId, ct).ConfigureAwait(false); if (binding is null) return null; return new RunGitHubCapabilitySnapshotRecord @@ -1596,6 +1816,96 @@ public async Task CaptureRootCapabilitySnapsho }; } + private async Task GetActiveCopilotBindingOrPlatformDefaultAsync( + string projectId, + CancellationToken ct) + { + var projectBinding = await db.ProjectCopilotBindings.AsNoTracking() + .Where(x => x.ProjectId == projectId && x.Status == GitHubBindingStatus.Active && x.DeactivatedAt == null) + .Select(x => new CopilotBindingSnapshotSource( + x.Id, + x.CredentialReference, + x.CredentialVersion, + x.GrantDigest)) + .SingleOrDefaultAsync(ct) + .ConfigureAwait(false); + if (projectBinding is not null) + return projectBinding; + + var platformBinding = await db.PlatformDefaultCopilotBindings.AsNoTracking() + .Where(x => x.Id == PlatformDefaultCopilotBindingRecord.SingletonId && + x.Status == GitHubBindingStatus.Active && + x.DeactivatedAt == null) + .Select(x => new CopilotBindingSnapshotSource( + x.Id, + x.CredentialReference, + x.CredentialVersion, + x.GrantDigest)) + .SingleOrDefaultAsync(ct) + .ConfigureAwait(false); + return platformBinding; + } + + internal async Task GetLiveAutomationCopilotBindingAsync( + string projectId, + string bindingId, + string grantDigest, + CancellationToken ct = default) + { + if (string.Equals(bindingId, PlatformDefaultCopilotBindingRecord.SingletonId, StringComparison.Ordinal)) + { + return await db.PlatformDefaultCopilotBindings.AsNoTracking() + .Where(x => x.Id == bindingId && + x.GrantDigest == grantDigest && + x.Status == GitHubBindingStatus.Active && + x.DeactivatedAt == null) + .Select(x => new CopilotBindingSnapshotSource( + x.Id, + x.CredentialReference, + x.CredentialVersion, + x.GrantDigest)) + .SingleOrDefaultAsync(ct) + .ConfigureAwait(false); + } + + return await db.ProjectCopilotBindings.AsNoTracking() + .Where(x => x.Id == bindingId && + x.ProjectId == projectId && + x.GrantDigest == grantDigest && + x.Status == GitHubBindingStatus.Active && + x.DeactivatedAt == null) + .Select(x => new CopilotBindingSnapshotSource( + x.Id, + x.CredentialReference, + x.CredentialVersion, + x.GrantDigest)) + .SingleOrDefaultAsync(ct) + .ConfigureAwait(false); + } + + private async Task IsLiveCopilotBindingAsync( + string projectId, + string bindingId, + string grantDigest, + CancellationToken ct) + { + if (string.Equals(bindingId, PlatformDefaultCopilotBindingRecord.SingletonId, StringComparison.Ordinal)) + { + return await db.PlatformDefaultCopilotBindings.AsNoTracking().AnyAsync(binding => + binding.Id == bindingId && + binding.GrantDigest == grantDigest && + binding.Status == GitHubBindingStatus.Active && + binding.DeactivatedAt == null, ct).ConfigureAwait(false); + } + + return await db.ProjectCopilotBindings.AsNoTracking().AnyAsync(binding => + binding.Id == bindingId && + binding.ProjectId == projectId && + binding.GrantDigest == grantDigest && + binding.Status == GitHubBindingStatus.Active && + binding.DeactivatedAt == null, ct).ConfigureAwait(false); + } + public async Task AppendAuditAsync(GitHubAuditRecord audit, CancellationToken ct = default) { EnsureSafe(audit); @@ -1625,6 +1935,12 @@ private static void EnsureAuthorizationTransaction(GitHubAuthorizationRecord aut nameof(authorization)); } + private static void EnsurePlatformDefaultCopilotBinding(PlatformDefaultCopilotBindingRecord binding) + { + if (!string.Equals(binding.Id, PlatformDefaultCopilotBindingRecord.SingletonId, StringComparison.Ordinal)) + throw new ArgumentException("Platform default Copilot binding must use the singleton id.", nameof(binding)); + } + private static void EnsureSafe(object record) { if (SensitiveDataRedactor.ContainsSensitiveValue(JsonSerializer.Serialize(record))) diff --git a/apps/Agentweaver.Api/Auth/PlatformDefaultCopilotBindingService.cs b/apps/Agentweaver.Api/Auth/PlatformDefaultCopilotBindingService.cs new file mode 100644 index 000000000..2f934ca44 --- /dev/null +++ b/apps/Agentweaver.Api/Auth/PlatformDefaultCopilotBindingService.cs @@ -0,0 +1,580 @@ +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Agentweaver.Api.Memory; +using Agentweaver.Api.Security; + +namespace Agentweaver.Api.Auth; + +public enum PlatformDefaultCopilotBindingOutcome +{ + Success, + HumanEntraSubjectRequired, + PlatformAdminRequired, + AuthorizationTransactionInvalid, + AuthorizationTransactionConsumed, + GitHubBindingUnavailable, +} + +public sealed record PlatformDefaultCopilotBindingBeginResult( + PlatformDefaultCopilotBindingOutcome Outcome, + string? AuthorizationUrl, + string? TransactionId, + DateTimeOffset? ExpiresAt) +{ + [System.Text.Json.Serialization.JsonIgnore] + public string? CallbackCookie { get; init; } +} + +public sealed record PlatformDefaultCopilotBindingConnectionResult( + PlatformDefaultCopilotBindingOutcome Outcome, + bool Connected, + string? GitHubLogin); + +internal sealed class PlatformDefaultCopilotBindingService( + IConfiguration configuration, + GitHubConnectionsPersistenceStore persistence, + ISecretStore secretStore, + IGitHubConnectionsCredentialVault credentialVault, + IHttpClientFactory httpClientFactory, + CopilotAppRegistrationService registration, + ILogger logger) +{ + private const string CookieName = "__Host-agentweaver-platform-copilot-app-auth"; + private const string ProjectCallbackSuffix = "/auth/github/copilot-app/callback"; + private const string PlatformCallbackSuffix = "/auth/github/platform-default-copilot/callback"; + private static readonly TimeSpan TransactionLifetime = TimeSpan.FromMinutes(10); + private static readonly TimeSpan ProviderTimeout = TimeSpan.FromSeconds(10); + + private readonly string _baseUrl = configuration["Auth:CopilotApp:BaseUrl"] ?? "https://github.com"; + private readonly string? _clientId = configuration["Auth:CopilotApp:ClientId"]; + private readonly string? _clientSecret = configuration["Auth:CopilotApp:ClientSecret"]; + private readonly string? _configuredCallbackUrl = configuration["Auth:CopilotApp:CallbackUrl"]; + private readonly string _scopes = configuration["Auth:CopilotApp:Scopes"] ?? "read:user"; + + public async Task BeginAsync( + CallerContext caller, + ClaimsPrincipal principal, + CancellationToken ct = default) + { + if (HumanEntraSubjectAuthorization.Evaluate(caller, principal) != HumanEntraSubjectState.Allowed) + return new(PlatformDefaultCopilotBindingOutcome.HumanEntraSubjectRequired, null, null, null); + if (!IsPlatformAdmin(caller)) + return new(PlatformDefaultCopilotBindingOutcome.PlatformAdminRequired, null, null, null); + if (!IsConfigurationValid() || + await registration.ValidateAsync(ct).ConfigureAwait(false) != CopilotAppRegistrationState.Ready) + return new(PlatformDefaultCopilotBindingOutcome.GitHubBindingUnavailable, null, null, null); + + var state = CreateRandomValue(); + var transactionId = GitHubConnectionsPersistenceStore.CreateExternalTransactionId(); + var cookie = CreateRandomValue(); + var verifier = CreateRandomValue(); + var now = DateTimeOffset.UtcNow; + var expiresAt = now.Add(TransactionLifetime); + var verifierReference = $"copilot-app-platform-pkce-{transactionId}"; + await secretStore.SetSecretAsync(verifierReference, verifier, ct: ct).ConfigureAwait(false); + try + { + await persistence.AddAuthorizationAsync(new GitHubAuthorizationRecord + { + State = state, + ExternalTransactionId = transactionId, + AppKind = GitHubAppKind.Copilot, + Purpose = GitHubAuthorizationPurpose.PlatformDefaultCopilot, + EntraObjectId = caller.EntraObjectId!, + ProjectId = null, + ExpiresAtUnixMilliseconds = expiresAt.ToUnixTimeMilliseconds(), + ReturnRouteKey = "platform-settings", + PkceVerifierProtected = verifierReference, + CallbackCookieHash = HashCookie(cookie), + Status = GitHubAuthorizationStatus.Pending, + CreatedAt = now, + }, ct).ConfigureAwait(false); + } + catch + { + await WriteTombstoneAsync(verifierReference, CancellationToken.None).ConfigureAwait(false); + return new(PlatformDefaultCopilotBindingOutcome.GitHubBindingUnavailable, null, null, null); + } + + return new(PlatformDefaultCopilotBindingOutcome.Success, BuildAuthorizationUrl(state, verifier), transactionId, expiresAt) + { + CallbackCookie = cookie, + }; + } + + public async Task CompleteBrowserCallbackAsync( + string? browserSessionId, + string? browserEntraObjectId, + string? state, + string? code, + string? callbackCookie, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(state)) + return PlatformDefaultCopilotBindingOutcome.AuthorizationTransactionInvalid; + var transaction = await persistence.GetPlatformDefaultCopilotAuthorizationTransactionAsync(state, ct).ConfigureAwait(false); + if (transaction is null || + (transaction.BrowserSessionId is not null && + (!string.Equals(transaction.BrowserSessionId, browserSessionId, StringComparison.Ordinal) || + !string.Equals(transaction.EntraObjectId, browserEntraObjectId, StringComparison.Ordinal))) || + string.IsNullOrWhiteSpace(callbackCookie) || + !FixedTimeCookieHashEquals(transaction.CallbackCookieHash, callbackCookie)) + return PlatformDefaultCopilotBindingOutcome.AuthorizationTransactionInvalid; + if (DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() > transaction.ExpiresAtUnixMilliseconds) + { + await WriteTombstoneAsync(transaction.PkceVerifierProtected, CancellationToken.None).ConfigureAwait(false); + return PlatformDefaultCopilotBindingOutcome.AuthorizationTransactionInvalid; + } + + return await ClaimAndCompleteAsync(transaction, code, ct).ConfigureAwait(false); + } + + public async Task GetConnectionAsync( + CallerContext caller, + ClaimsPrincipal principal, + CancellationToken ct = default) + { + if (HumanEntraSubjectAuthorization.Evaluate(caller, principal) != HumanEntraSubjectState.Allowed) + return new(PlatformDefaultCopilotBindingOutcome.HumanEntraSubjectRequired, false, null); + if (!IsPlatformAdmin(caller)) + return new(PlatformDefaultCopilotBindingOutcome.PlatformAdminRequired, false, null); + return await GetConnectionCoreAsync(ct).ConfigureAwait(false); + } + + public async Task DisconnectAsync( + CallerContext caller, + ClaimsPrincipal principal, + CancellationToken ct = default) + { + if (HumanEntraSubjectAuthorization.Evaluate(caller, principal) != HumanEntraSubjectState.Allowed) + return PlatformDefaultCopilotBindingOutcome.HumanEntraSubjectRequired; + if (!IsPlatformAdmin(caller)) + return PlatformDefaultCopilotBindingOutcome.PlatformAdminRequired; + + RepoAppCredentialReference? reference; + try + { + reference = await persistence.RevokePlatformDefaultCopilotBindingAsync( + CreateAudit(caller.EntraObjectId!, GitHubAuditOutcome.Succeeded, GitHubAuditReasonCode.None, null), + ct).ConfigureAwait(false); + } + catch (Microsoft.EntityFrameworkCore.DbUpdateException) + { + return PlatformDefaultCopilotBindingOutcome.GitHubBindingUnavailable; + } + + if (reference is null) + return PlatformDefaultCopilotBindingOutcome.GitHubBindingUnavailable; + try + { + var secret = await secretStore.GetSecretAsync(reference.CredentialReference, ct).ConfigureAwait(false); + var credential = secret.Found ? DeserializeCredential(secret.Value) : null; + if (await ShouldRevokeCredentialAsync(reference.Id, credential, null, ct).ConfigureAwait(false)) + await RevokeWithProviderAsync(credential?.AccessToken, ct).ConfigureAwait(false); + await DeleteCredentialAsync(reference.CredentialReference, ct).ConfigureAwait(false); + } + catch + { + // Durable revocation already succeeded; provider revoke remains best-effort only. + } + + return PlatformDefaultCopilotBindingOutcome.Success; + } + + public Task GetCallbackRedirectAsync( + PlatformDefaultCopilotBindingOutcome outcome, + CancellationToken ct = default) + { + _ = ct; + var frontend = (configuration["Auth:CopilotApp:FrontendUrl"] ?? "http://localhost:5173").TrimEnd('/'); + return Task.FromResult($"{frontend}/platform-settings?copilot_app_auth={ToStateCode(outcome)}"); + } + + public static void SetCallbackCookie(HttpContext context, string value) => + context.Response.Cookies.Append(CookieName, value, CookieOptions()); + public static string? ReadCallbackCookie(HttpContext context) => + context.Request.Cookies.TryGetValue(CookieName, out var value) ? value : null; + public static void ClearCallbackCookie(HttpContext context) => + context.Response.Cookies.Append(CookieName, string.Empty, CookieOptions(DateTimeOffset.UnixEpoch)); + public static string ToStateCode(PlatformDefaultCopilotBindingOutcome outcome) => outcome switch + { + PlatformDefaultCopilotBindingOutcome.HumanEntraSubjectRequired => "human_entra_subject_required", + PlatformDefaultCopilotBindingOutcome.PlatformAdminRequired => "platform_admin_required", + PlatformDefaultCopilotBindingOutcome.AuthorizationTransactionInvalid => "authorization_transaction_invalid", + PlatformDefaultCopilotBindingOutcome.AuthorizationTransactionConsumed => "authorization_transaction_consumed", + PlatformDefaultCopilotBindingOutcome.GitHubBindingUnavailable => "github_binding_unavailable", + _ => "success", + }; + + private async Task GetConnectionCoreAsync(CancellationToken ct) + { + var binding = await persistence.GetActivePlatformDefaultCopilotBindingAsync(ct).ConfigureAwait(false); + if (binding is null) + return new(PlatformDefaultCopilotBindingOutcome.Success, false, null); + + var secret = await secretStore.GetSecretAsync(binding.CredentialReference, ct).ConfigureAwait(false); + var credential = secret.Found ? DeserializeCredential(secret.Value) : null; + if (credential is null || !string.Equals(credential.Status, "signed-in", StringComparison.Ordinal)) + { + logger.LogWarning( + "Platform-default Copilot connection has an active binding record but its credential secret is {SecretState}.", + !secret.Found ? "missing" : credential is null ? "unparseable" : $"status={credential.Status}"); + return new(PlatformDefaultCopilotBindingOutcome.GitHubBindingUnavailable, false, null); + } + + var login = IsGitHubLogin(credential.GitHubLogin) + ? credential.GitHubLogin + : !string.IsNullOrWhiteSpace(credential.AccessToken) + ? await GetGitHubLoginAsync(credential.AccessToken, ct).ConfigureAwait(false) + : null; + return new(PlatformDefaultCopilotBindingOutcome.Success, true, login); + } + + private async Task ClaimAndCompleteAsync( + PlatformDefaultCopilotAuthorizationTransaction transaction, + string? code, + CancellationToken ct) + { + var claimed = await persistence.ClaimAuthorizationAsync( + transaction.State, transaction.EntraObjectId, DateTimeOffset.UtcNow, ct).ConfigureAwait(false); + if (claimed != AuthorizationClaimResult.Claimed) + return claimed == AuthorizationClaimResult.Consumed + ? PlatformDefaultCopilotBindingOutcome.AuthorizationTransactionConsumed + : PlatformDefaultCopilotBindingOutcome.AuthorizationTransactionInvalid; + var registrationState = await registration.ValidateAsync(ct).ConfigureAwait(false); + if (registrationState != CopilotAppRegistrationState.Ready) + { + logger.LogWarning( + "Platform-default Copilot binding failed: registration validation returned {RegistrationState} instead of Ready.", + registrationState); + await WriteTombstoneAsync(transaction.PkceVerifierProtected, CancellationToken.None).ConfigureAwait(false); + await CompleteFailureAsync(transaction, GitHubAuditReasonCode.BindingUnavailable, CancellationToken.None) + .ConfigureAwait(false); + return PlatformDefaultCopilotBindingOutcome.GitHubBindingUnavailable; + } + + string? credentialReference = null; + try + { + if (string.IsNullOrWhiteSpace(code)) + throw new InvalidOperationException("Callback did not include an authorization code."); + var verifier = await secretStore.GetSecretAsync(transaction.PkceVerifierProtected, ct).ConfigureAwait(false); + if (!verifier.Found || string.IsNullOrWhiteSpace(verifier.Value)) + throw new InvalidOperationException("PKCE verifier secret was not found or had expired."); + var credential = await ExchangeCodeAsync(code, verifier.Value, ct).ConfigureAwait(false); + await WriteTombstoneAsync(transaction.PkceVerifierProtected, ct).ConfigureAwait(false); + if (credential is null || string.IsNullOrWhiteSpace(credential.GitHubLogin)) + throw new InvalidOperationException("Token exchange with GitHub did not return a usable credential or login."); + + var version = CreateRandomValue(); + credentialReference = $"copilot-app-platform-default-{version}"; + await credentialVault.WriteAsync( + GitHubConnectionsCredentialLocator.ForCopilotBinding(credentialReference), + JsonSerializer.Serialize(credential with { Status = "signed-in" }), + ct).ConfigureAwait(false); + var completed = await persistence.CompletePlatformDefaultCopilotAuthorizationAsync( + transaction.State, + new PlatformDefaultCopilotBindingRecord + { + Id = PlatformDefaultCopilotBindingRecord.SingletonId, + EntraObjectId = transaction.EntraObjectId, + CredentialReference = credentialReference, + CredentialVersion = version, + GrantDigest = CreateGrantDigest(version), + Status = GitHubBindingStatus.Active, + BoundAt = DateTimeOffset.UtcNow, + DeactivatedAt = null, + }, + CreateAudit(transaction.EntraObjectId, GitHubAuditOutcome.Succeeded, GitHubAuditReasonCode.None, version), + ct).ConfigureAwait(false); + if (!completed.Completed) + throw new InvalidOperationException("Persisting the platform-default Copilot binding record failed."); + if (completed.ReplacedCredential is not null) + await RevokeReplacedCredentialAsync( + completed.ReplacedCredential, + credential.GitHubLogin, + CancellationToken.None).ConfigureAwait(false); + return PlatformDefaultCopilotBindingOutcome.Success; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Platform-default Copilot binding failed to complete."); + if (!string.IsNullOrWhiteSpace(credentialReference)) + await DeleteCredentialAsync(credentialReference, CancellationToken.None).ConfigureAwait(false); + await WriteTombstoneAsync(transaction.PkceVerifierProtected, CancellationToken.None).ConfigureAwait(false); + await CompleteFailureAsync(transaction, GitHubAuditReasonCode.BindingUnavailable, CancellationToken.None).ConfigureAwait(false); + return PlatformDefaultCopilotBindingOutcome.GitHubBindingUnavailable; + } + } + + private async Task CompleteFailureAsync( + PlatformDefaultCopilotAuthorizationTransaction transaction, + GitHubAuditReasonCode reason, + CancellationToken ct) => + await persistence.CompleteCopilotAuthorizationFailureAsync( + transaction.State, + CreateAudit(transaction.EntraObjectId, GitHubAuditOutcome.Failed, reason, null), + ct).ConfigureAwait(false); + + private bool IsConfigurationValid() => + !string.IsNullOrWhiteSpace(_clientId) && + !string.IsNullOrWhiteSpace(_clientSecret) && + !string.IsNullOrWhiteSpace(_configuredCallbackUrl) && + _configuredCallbackUrl.EndsWith(ProjectCallbackSuffix, StringComparison.Ordinal) && + !string.IsNullOrWhiteSpace(GetPlatformCallbackUrl()) && + string.IsNullOrWhiteSpace(configuration["Auth:CopilotApp:PrivateKey"]) && + !SameConfiguredValue(_clientId, configuration["Auth:RepoApp:ClientId"]) && + !SameConfiguredValue(_clientSecret, configuration["Auth:RepoApp:ClientSecret"]) && + !SameConfiguredValue(configuration["Auth:CopilotApp:SecretPath"], configuration["Auth:RepoApp:SecretPath"]) && + !string.Equals(configuration["Auth:RepoApp:RequestUserAuthorizationDuringInstallation"], "true", StringComparison.OrdinalIgnoreCase); + + private static bool SameConfiguredValue(string? first, string? second) => + !string.IsNullOrWhiteSpace(first) && + !string.IsNullOrWhiteSpace(second) && + string.Equals(first, second, StringComparison.Ordinal); + + private string BuildAuthorizationUrl(string state, string verifier) => + $"{_baseUrl.TrimEnd('/')}/login/oauth/authorize" + + $"?client_id={Uri.EscapeDataString(_clientId!)}" + + $"&redirect_uri={Uri.EscapeDataString(GetPlatformCallbackUrl())}" + + $"&scope={Uri.EscapeDataString(_scopes)}" + + $"&state={Uri.EscapeDataString(state)}" + + $"&code_challenge={Uri.EscapeDataString(ProjectCopilotBindingService.CreateS256Challenge(verifier))}" + + "&code_challenge_method=S256"; + + private string GetPlatformCallbackUrl() => + _configuredCallbackUrl!.EndsWith(ProjectCallbackSuffix, StringComparison.Ordinal) + ? _configuredCallbackUrl[..^ProjectCallbackSuffix.Length] + PlatformCallbackSuffix + : throw new InvalidOperationException("Copilot App callback URL is invalid."); + + private async Task ExchangeCodeAsync(string code, string verifier, CancellationToken ct) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(ProviderTimeout); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl.TrimEnd('/')}/login/oauth/access_token") + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = _clientId!, + ["client_secret"] = _clientSecret!, + ["code"] = code, + ["redirect_uri"] = GetPlatformCallbackUrl(), + ["code_verifier"] = verifier, + }), + }; + request.Headers.Accept.ParseAdd("application/json"); + try + { + using var response = await httpClientFactory.CreateClient("github-authz") + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.OK || response.Content.Headers.ContentLength is > 64 * 1024) + return null; + var body = await ReadBoundedAsync(response.Content, timeout.Token).ConfigureAwait(false); + var provider = JsonSerializer.Deserialize(body); + if (provider is not { Error: null, AccessToken: not null } || + string.IsNullOrWhiteSpace(provider.AccessToken)) + return null; + + var login = await GetGitHubLoginAsync(provider.AccessToken, timeout.Token).ConfigureAwait(false); + return login is null ? null : new("signed-in", provider.AccessToken, provider.RefreshToken, login); + } + catch (Exception ex) when (ex is HttpRequestException or JsonException || + (ex is OperationCanceledException && !ct.IsCancellationRequested)) + { + return null; + } + } + + private async Task GetGitHubLoginAsync(string accessToken, CancellationToken ct) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(ProviderTimeout); + using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + request.Headers.UserAgent.ParseAdd("Agentweaver"); + request.Headers.Accept.ParseAdd("application/vnd.github+json"); + try + { + using var response = await httpClientFactory.CreateClient("github-authz") + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.OK || response.Content.Headers.ContentLength is > 64 * 1024) + return null; + var body = await ReadBoundedAsync(response.Content, timeout.Token).ConfigureAwait(false); + var provider = JsonSerializer.Deserialize(body); + return provider is not null && IsGitHubLogin(provider.Login) ? provider.Login : null; + } + catch (Exception ex) when (ex is HttpRequestException or JsonException || + (ex is OperationCanceledException && !ct.IsCancellationRequested)) + { + return null; + } + } + + private async Task RevokeWithProviderAsync(string? accessToken, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(accessToken)) + return; + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(ProviderTimeout); + using var request = new HttpRequestMessage(HttpMethod.Delete, + $"{_baseUrl.TrimEnd('/')}/applications/{Uri.EscapeDataString(_clientId!)}/grant") + { + Content = new StringContent(JsonSerializer.Serialize(new { access_token = accessToken }), Encoding.UTF8, "application/json"), + }; + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue( + "Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_clientId}:{_clientSecret}"))); + try { using var _ = await httpClientFactory.CreateClient("github-authz").SendAsync(request, timeout.Token).ConfigureAwait(false); } + catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException) { } + } + + private static CookieOptions CookieOptions(DateTimeOffset? expires = null) => new() + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = "/", + Expires = expires, + MaxAge = expires is null ? TransactionLifetime : null, + }; + + private static string CreateRandomValue() => ToBase64Url(RandomNumberGenerator.GetBytes(32)); + private static string ToBase64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + private static string HashCookie(string value) => Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(value))); + private static bool FixedTimeCookieHashEquals(string expected, string value) + { + try { return CryptographicOperations.FixedTimeEquals(Convert.FromBase64String(expected), SHA256.HashData(Encoding.UTF8.GetBytes(value))); } + catch (FormatException) { return false; } + } + + private static string CreateGrantDigest(string version) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"copilot:platform-default:{version}"))).ToLowerInvariant(); + + private async Task WriteTombstoneAsync(string reference, CancellationToken ct) => + await secretStore.SetSecretAsync(reference, """{"status":"revoked"}""", ct: ct).ConfigureAwait(false); + + private async Task DeleteCredentialAsync(string reference, CancellationToken ct) => + await credentialVault.TombstoneAndDeleteAsync( + GitHubConnectionsCredentialLocator.ForCopilotBinding(reference), + ct).ConfigureAwait(false); + + private static CopilotCredential? DeserializeCredential(string? value) + { + try { return string.IsNullOrWhiteSpace(value) ? null : JsonSerializer.Deserialize(value); } + catch (JsonException) { return null; } + } + + private static GitHubAuditRecord CreateAudit( + string entraObjectId, + GitHubAuditOutcome outcome, + GitHubAuditReasonCode reason, + string? version) => + new() + { + EntraObjectId = entraObjectId, + ActorKind = GitHubAuditActorKind.HumanEntraSubject, + Action = GitHubAuditAction.BindingChanged, + ResourceId = PlatformDefaultCopilotBindingRecord.SingletonId, + AppKind = GitHubAppKind.Copilot, + CapabilityPurpose = GitHubCapabilityPurpose.UnattendedCopilot, + Outcome = outcome, + ReasonCode = reason, + CorrelationId = Guid.NewGuid().ToString("N"), + OccurredAt = DateTimeOffset.UtcNow, + GrantDigest = version is null ? null : CreateGrantDigest(version), + }; + + private static async Task ReadBoundedAsync(HttpContent content, CancellationToken ct) + { + await using var stream = await content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var buffer = new MemoryStream(); + var chunk = new byte[4096]; + while (true) + { + var read = await stream.ReadAsync(chunk, ct).ConfigureAwait(false); + if (read == 0) return Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length); + if (buffer.Length + read > 64 * 1024) throw new JsonException(); + buffer.Write(chunk, 0, read); + } + } + + private async Task RevokeReplacedCredentialAsync( + RepoAppCredentialReference reference, + string? replacementGitHubLogin, + CancellationToken ct) + { + try + { + var secret = await secretStore.GetSecretAsync(reference.CredentialReference, ct).ConfigureAwait(false); + var replacedCredential = secret.Found && !string.IsNullOrWhiteSpace(secret.Value) + ? DeserializeCredential(secret.Value) + : null; + if (await ShouldRevokeCredentialAsync(reference.Id, replacedCredential, replacementGitHubLogin, ct).ConfigureAwait(false)) + await RevokeWithProviderAsync(replacedCredential?.AccessToken, ct).ConfigureAwait(false); + await DeleteCredentialAsync(reference.CredentialReference, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Platform-default Copilot binding replaced credential {CredentialReference} but cleanup failed after commit.", + reference.CredentialReference); + } + } + + private async Task ShouldRevokeCredentialAsync( + string bindingId, + CopilotCredential? credential, + string? replacementGitHubLogin, + CancellationToken ct) + { + if (credential is null || + string.IsNullOrWhiteSpace(credential.AccessToken) || + string.IsNullOrWhiteSpace(credential.GitHubLogin)) + return false; + if (!string.IsNullOrWhiteSpace(replacementGitHubLogin) && + string.Equals(credential.GitHubLogin, replacementGitHubLogin, StringComparison.OrdinalIgnoreCase)) + return false; + var otherBindings = await persistence.ListActiveCopilotBindingsAsync(bindingId, ct).ConfigureAwait(false); + foreach (var otherBinding in otherBindings) + { + var otherSecret = await secretStore.GetSecretAsync(otherBinding.CredentialReference, ct).ConfigureAwait(false); + var otherCredential = otherSecret.Found && !string.IsNullOrWhiteSpace(otherSecret.Value) + ? DeserializeCredential(otherSecret.Value) + : null; + if (string.Equals(credential.GitHubLogin, otherCredential?.GitHubLogin, StringComparison.OrdinalIgnoreCase)) + return false; + } + + return true; + } + + private static bool IsGitHubLogin(string? value) => + !string.IsNullOrWhiteSpace(value) && + value.Length <= 39 && + Regex.IsMatch(value, "^[A-Za-z\\d](?:[A-Za-z\\d-]{0,37}[A-Za-z\\d])?$"); + + private static bool IsPlatformAdmin(CallerContext caller) => + caller.PlatformRoles.Contains(PlatformRoles.PlatformAdmin, StringComparer.Ordinal); + + private sealed record CopilotCredential( + string? Status, + string? AccessToken, + string? RefreshToken, + string? GitHubLogin = null); + + private sealed class ProviderTokenResponse + { + [System.Text.Json.Serialization.JsonPropertyName("access_token")] public string? AccessToken { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("refresh_token")] public string? RefreshToken { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("error")] public string? Error { get; init; } + } + + private sealed class ProviderUserResponse + { + [System.Text.Json.Serialization.JsonPropertyName("login")] public string? Login { get; init; } + } +} diff --git a/apps/Agentweaver.Api/Endpoints/AuthEndpoints.cs b/apps/Agentweaver.Api/Endpoints/AuthEndpoints.cs index f39fe04bd..d26fe1ecf 100644 --- a/apps/Agentweaver.Api/Endpoints/AuthEndpoints.cs +++ b/apps/Agentweaver.Api/Endpoints/AuthEndpoints.cs @@ -1,6 +1,7 @@ using Agentweaver.Api.Auth; using Agentweaver.Api.Contracts; using Agentweaver.Api.Security; +using System.Text.Json; using System.Text.Json.Serialization; namespace Agentweaver.Api.Endpoints; @@ -43,9 +44,18 @@ public static void MapAuthEndpoints(this WebApplication app) }); }); - app.MapGet("/api/auth/session", (HttpContext httpContext) => + app.MapGet("/api/auth/session", async ( + HttpContext httpContext, + ByokProviderConfigurationService byokSettings, + GitHubConnectionsPersistenceStore persistence, + ISecretStore secretStore, + CancellationToken ct) => { var caller = ApiKeyAuthMiddleware.GetCaller(httpContext); + var platformBinding = await persistence.GetActivePlatformDefaultCopilotBindingAsync(ct).ConfigureAwait(false); + var aiConfigured = + await HasByokConfigurationAsync(byokSettings, ct).ConfigureAwait(false) || + await HasUsablePlatformDefaultCopilotBindingAsync(platformBinding, secretStore, ct).ConfigureAwait(false); return Results.Ok(new { authenticated = true, @@ -56,6 +66,7 @@ public static void MapAuthEndpoints(this WebApplication app) avatar_url = (string?)null, entra_object_id = caller.EntraObjectId, platform_roles = caller.PlatformRoles, + ai_configured = aiConfigured, }); }); @@ -283,6 +294,62 @@ public static void MapAuthEndpoints(this WebApplication app) return Results.Ok(new SessionExchangeResponse(accessToken, login)); }).AllowAnonymous(); } + + private static async Task HasUsablePlatformDefaultCopilotBindingAsync( + RepoAppCredentialReference? binding, + ISecretStore secretStore, + CancellationToken ct) + { + if (binding is null) + return false; + + var secret = await secretStore.GetSecretAsync(binding.CredentialReference, ct).ConfigureAwait(false); + if (!secret.Found || string.IsNullOrWhiteSpace(secret.Value)) + return false; + + try + { + using var document = JsonDocument.Parse(secret.Value); + if (document.RootElement.ValueKind != JsonValueKind.Object) + return false; + var status = GetJsonString(document.RootElement, "status"); + var accessToken = GetJsonString(document.RootElement, "access_token", "accessToken"); + return string.Equals(status, "signed-in", StringComparison.Ordinal) && + !string.IsNullOrWhiteSpace(accessToken); + } + catch (JsonException) + { + return false; + } + } + + private static async Task HasByokConfigurationAsync( + ByokProviderConfigurationService byokSettings, + CancellationToken ct) + { + try + { + return await byokSettings.GetAsync(ct).ConfigureAwait(false) is not null; + } + catch (JsonException) + { + return false; + } + } + + private static string? GetJsonString(JsonElement element, params string[] propertyNames) + { + foreach (var property in element.EnumerateObject()) + { + foreach (var propertyName in propertyNames) + { + if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) + return property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : null; + } + } + + return null; + } } internal static class EntraOAuthStateCookie diff --git a/apps/Agentweaver.Api/Endpoints/PlatformDefaultCopilotBindingEndpoints.cs b/apps/Agentweaver.Api/Endpoints/PlatformDefaultCopilotBindingEndpoints.cs new file mode 100644 index 000000000..b3c48b5df --- /dev/null +++ b/apps/Agentweaver.Api/Endpoints/PlatformDefaultCopilotBindingEndpoints.cs @@ -0,0 +1,125 @@ +using Agentweaver.Api.Auth; +using Agentweaver.Api.Security; + +namespace Agentweaver.Api.Endpoints; + +public static class PlatformDefaultCopilotBindingEndpoints +{ + public static void MapPlatformDefaultCopilotBindingEndpoints(this WebApplication app) + { + app.MapPost("/api/admin/platform-default-copilot/begin", async ( + HttpContext httpContext, + IConfiguration configuration, + GitHubConnectionsPersistenceStore persistence, + ISecretStore secretStore, + IGitHubConnectionsCredentialVault credentialVault, + IHttpClientFactory httpClientFactory, + CopilotAppRegistrationService registration, + ILogger logger, + CancellationToken ct) => + { + var service = new PlatformDefaultCopilotBindingService( + configuration, persistence, secretStore, credentialVault, httpClientFactory, registration, logger); + var result = await service.BeginAsync( + ApiKeyAuthMiddleware.GetCaller(httpContext), httpContext.User, ct).ConfigureAwait(false); + if (result.Outcome != PlatformDefaultCopilotBindingOutcome.Success) + return CopilotBindingFailure(result.Outcome); + + PlatformDefaultCopilotBindingService.SetCallbackCookie(httpContext, result.CallbackCookie!); + return Results.Ok(new + { + authorization_url = result.AuthorizationUrl, + transaction_id = result.TransactionId, + expires_at = result.ExpiresAt, + }); + }) + .WithName("BeginPlatformDefaultCopilotAuthorization") + .WithTags("Platform settings", "GitHub Copilot"); + + app.MapGet("/auth/github/platform-default-copilot/callback", async ( + HttpContext httpContext, + string? code, + string? state, + string? error, + IConfiguration configuration, + BrowserEntraSessionService browserSessions, + GitHubConnectionsPersistenceStore persistence, + ISecretStore secretStore, + IGitHubConnectionsCredentialVault credentialVault, + IHttpClientFactory httpClientFactory, + CopilotAppRegistrationService registration, + ILogger logger, + CancellationToken ct) => + { + var service = new PlatformDefaultCopilotBindingService( + configuration, persistence, secretStore, credentialVault, httpClientFactory, registration, logger); + var cookie = PlatformDefaultCopilotBindingService.ReadCallbackCookie(httpContext); + PlatformDefaultCopilotBindingService.ClearCallbackCookie(httpContext); + var browserSession = await browserSessions.GetCurrentAsync(httpContext, ct).ConfigureAwait(false); + var outcome = await service.CompleteBrowserCallbackAsync( + browserSession?.Id, + browserSession?.EntraObjectId, + state, + string.IsNullOrWhiteSpace(error) ? code : null, + cookie, + ct).ConfigureAwait(false); + return Results.Redirect(await service.GetCallbackRedirectAsync(outcome, ct).ConfigureAwait(false)); + }).AllowAnonymous(); + + app.MapGet("/api/admin/platform-default-copilot/status", async ( + HttpContext httpContext, + IConfiguration configuration, + GitHubConnectionsPersistenceStore persistence, + ISecretStore secretStore, + IGitHubConnectionsCredentialVault credentialVault, + IHttpClientFactory httpClientFactory, + CopilotAppRegistrationService registration, + ILogger logger, + CancellationToken ct) => + { + var service = new PlatformDefaultCopilotBindingService( + configuration, persistence, secretStore, credentialVault, httpClientFactory, registration, logger); + var result = await service.GetConnectionAsync( + ApiKeyAuthMiddleware.GetCaller(httpContext), httpContext.User, ct).ConfigureAwait(false); + return result.Outcome == PlatformDefaultCopilotBindingOutcome.Success + ? Results.Ok(new + { + connected = result.Connected, + github_login = result.GitHubLogin, + }) + : CopilotBindingFailure(result.Outcome); + }) + .WithName("GetPlatformDefaultCopilotStatus") + .WithTags("Platform settings", "GitHub Copilot"); + + app.MapPost("/api/admin/platform-default-copilot/disconnect", async ( + HttpContext httpContext, + IConfiguration configuration, + GitHubConnectionsPersistenceStore persistence, + ISecretStore secretStore, + IGitHubConnectionsCredentialVault credentialVault, + IHttpClientFactory httpClientFactory, + CopilotAppRegistrationService registration, + ILogger logger, + CancellationToken ct) => + { + var service = new PlatformDefaultCopilotBindingService( + configuration, persistence, secretStore, credentialVault, httpClientFactory, registration, logger); + var outcome = await service.DisconnectAsync( + ApiKeyAuthMiddleware.GetCaller(httpContext), httpContext.User, ct).ConfigureAwait(false); + return outcome == PlatformDefaultCopilotBindingOutcome.Success + ? Results.NoContent() + : CopilotBindingFailure(outcome); + }) + .WithName("DisconnectPlatformDefaultCopilot") + .WithTags("Platform settings", "GitHub Copilot"); + } + + private static IResult CopilotBindingFailure(PlatformDefaultCopilotBindingOutcome outcome) + { + var statusCode = outcome is PlatformDefaultCopilotBindingOutcome.HumanEntraSubjectRequired or PlatformDefaultCopilotBindingOutcome.PlatformAdminRequired + ? StatusCodes.Status403Forbidden + : StatusCodes.Status409Conflict; + return Results.Json(new { error = PlatformDefaultCopilotBindingService.ToStateCode(outcome) }, statusCode: statusCode); + } +} diff --git a/apps/Agentweaver.Api/Migrations/20260831164652_AddPlatformDefaultCopilotBinding.Designer.cs b/apps/Agentweaver.Api/Migrations/20260831164652_AddPlatformDefaultCopilotBinding.Designer.cs new file mode 100644 index 000000000..01fa8c1c5 --- /dev/null +++ b/apps/Agentweaver.Api/Migrations/20260831164652_AddPlatformDefaultCopilotBinding.Designer.cs @@ -0,0 +1,1951 @@ +// +using System; +using Agentweaver.Api.Memory; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Agentweaver.Api.Migrations +{ + [DbContext(typeof(MemoryDbContext))] + [Migration("20260831164652_AddPlatformDefaultCopilotBinding")] + partial class AddPlatformDefaultCopilotBinding + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); + + modelBuilder.Entity("Agentweaver.Api.Auth.OAuth.BrowserEntraSession", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntraObjectId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("BrowserEntraSessions"); + }); + + modelBuilder.Entity("Agentweaver.Api.Auth.OAuth.EntraOAuthState", b => + { + b.Property("State") + .HasColumnType("TEXT"); + + b.Property("CodeVerifier") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.HasKey("State"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("EntraOAuthStates"); + }); + + modelBuilder.Entity("Agentweaver.Api.Auth.OAuth.WebSessionExchangeCode", b => + { + b.Property("Code") + .HasColumnType("TEXT"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("Login") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Code"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("WebSessionExchangeCodes"); + }); + + modelBuilder.Entity("Agentweaver.Api.Coordinator.CoordinatorAssemblyReviewRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AggregateTreeHash") + .HasColumnType("TEXT"); + + b.Property("CoordinatorFailedAt") + .HasColumnType("TEXT"); + + b.Property("CoordinatorFailureReason") + .HasColumnType("TEXT"); + + b.Property("CoordinatorRunId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DecisionJson") + .HasColumnType("TEXT"); + + b.Property("DecisionSubmittedAt") + .HasColumnType("TEXT"); + + b.Property("IntegrationBranch") + .HasColumnType("TEXT"); + + b.Property("OwnerUser") + .HasColumnType("TEXT"); + + b.Property("Reviewer") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CoordinatorRunId") + .IsUnique(); + + b.ToTable("AssemblyReviews"); + }); + + modelBuilder.Entity("Agentweaver.Api.Diagnostics.HeartbeatStatusRecord", b => + { + b.Property("PodName") + .HasColumnType("TEXT"); + + b.Property("ActedCount") + .HasColumnType("INTEGER"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("ErrorCount") + .HasColumnType("INTEGER"); + + b.Property("IntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("LastTickUtc") + .HasColumnType("TEXT"); + + b.HasKey("PodName"); + + b.ToTable("HeartbeatStatuses"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.AgentMemory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ApprovedAt") + .HasColumnType("TEXT"); + + b.Property("ApprovedBy") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Importance") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasColumnType("TEXT"); + + b.Property("SourceIdentity") + .HasColumnType("TEXT"); + + b.Property("SourceKind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("legacy"); + + b.Property("SourceRunId") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TrustState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("legacy"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "AgentName"); + + b.HasIndex("ProjectId", "Type"); + + b.ToTable("AgentMemory"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.AutomationActivationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("TEXT") + .HasColumnName("activated_at"); + + b.Property("AutomationKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("automation_key"); + + b.Property("CopilotBindingGrantDigest") + .HasColumnType("TEXT") + .HasColumnName("copilot_binding_grant_digest"); + + b.Property("CopilotBindingId") + .HasColumnType("TEXT") + .HasColumnName("copilot_binding_id"); + + b.Property("InstallationId") + .HasColumnType("INTEGER") + .HasColumnName("installation_id"); + + b.Property("InvalidatedAt") + .HasColumnType("TEXT") + .HasColumnName("invalidated_at"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("project_id"); + + b.Property("RepositoryGrantDigest") + .HasColumnType("TEXT") + .HasColumnName("repository_grant_digest"); + + b.Property("RepositoryId") + .HasColumnType("INTEGER") + .HasColumnName("repository_id"); + + b.Property("Status") + .HasColumnType("INTEGER") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId") + .IsUnique() + .HasDatabaseName("UX_automation_activations_active_project") + .HasFilter("status = 0"); + + b.HasIndex("InstallationId", "RepositoryId"); + + b.ToTable("automation_activations", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.AutomationInvocationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ActivationId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("activation_id"); + + b.Property("BacklogTaskId") + .HasColumnType("TEXT") + .HasColumnName("backlog_task_id"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("DeliveryId") + .HasColumnType("TEXT") + .HasColumnName("delivery_id"); + + b.Property("EventName") + .HasColumnType("TEXT") + .HasColumnName("event_name"); + + b.Property("InstallationId") + .HasColumnType("INTEGER") + .HasColumnName("installation_id"); + + b.Property("OccurrenceKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("occurrence_key"); + + b.Property("Outcome") + .HasColumnType("INTEGER") + .HasColumnName("outcome"); + + b.Property("PendingBacklogTaskId") + .HasColumnType("TEXT") + .HasColumnName("pending_backlog_task_id"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("project_id"); + + b.Property("ReceivedAt") + .HasColumnType("TEXT") + .HasColumnName("received_at"); + + b.Property("RepositoryId") + .HasColumnType("INTEGER") + .HasColumnName("repository_id"); + + b.HasKey("Id"); + + b.HasIndex("BacklogTaskId") + .IsUnique() + .HasDatabaseName("UX_automation_invocations_backlog_task_id") + .HasFilter("backlog_task_id IS NOT NULL"); + + b.HasIndex("DeliveryId") + .IsUnique() + .HasDatabaseName("UX_automation_invocations_delivery_id") + .HasFilter("delivery_id IS NOT NULL"); + + b.HasIndex("PendingBacklogTaskId") + .IsUnique() + .HasDatabaseName("UX_automation_invocations_pending_backlog_task_id") + .HasFilter("pending_backlog_task_id IS NOT NULL"); + + b.HasIndex("ProjectId"); + + b.HasIndex("ActivationId", "OccurrenceKey") + .IsUnique(); + + b.ToTable("automation_invocations", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.Decision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ApprovedAt") + .HasColumnType("TEXT"); + + b.Property("ApprovedBy") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Rationale") + .HasColumnType("TEXT"); + + b.Property("SourceIdentity") + .HasColumnType("TEXT"); + + b.Property("SourceKind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("legacy"); + + b.Property("SourceRunId") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SupersededById") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TrustState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("legacy"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SupersededById"); + + b.HasIndex("ProjectId", "AgentName"); + + b.HasIndex("ProjectId", "Status"); + + b.ToTable("Decisions"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.DecisionInboxEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DecisionId") + .HasColumnType("INTEGER"); + + b.Property("MergedAt") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Rationale") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceIdentity") + .HasColumnType("TEXT"); + + b.Property("SourceKind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("legacy"); + + b.Property("SourceRunId") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DecisionId"); + + b.HasIndex("ProjectId", "Slug") + .IsUnique(); + + b.HasIndex("ProjectId", "Status"); + + b.ToTable("DecisionInbox"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.DismissedNotification", b => + { + b.Property("User") + .HasColumnType("TEXT") + .HasColumnName("user"); + + b.Property("NotificationId") + .HasColumnType("TEXT") + .HasColumnName("notification_id"); + + b.Property("DismissedAt") + .HasColumnType("TEXT") + .HasColumnName("dismissed_at"); + + b.HasKey("User", "NotificationId"); + + b.ToTable("dismissed_notifications", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubAppAuthorizationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AppKind") + .HasColumnType("INTEGER") + .HasColumnName("app_kind"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CredentialReference") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("credential_reference"); + + b.Property("CredentialVersion") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("credential_version"); + + b.Property("EntraObjectId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("entra_object_id"); + + b.Property("GrantDigest") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("grant_digest"); + + b.Property("Purpose") + .HasColumnType("INTEGER") + .HasColumnName("purpose"); + + b.Property("RevokedAt") + .HasColumnType("TEXT") + .HasColumnName("revoked_at"); + + b.HasKey("Id"); + + b.HasIndex("EntraObjectId", "AppKind", "Purpose"); + + b.ToTable("github_app_authorizations", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubAuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("Action") + .HasColumnType("INTEGER") + .HasColumnName("action"); + + b.Property("ActorKind") + .HasColumnType("INTEGER") + .HasColumnName("actor_kind"); + + b.Property("AppKind") + .HasColumnType("INTEGER") + .HasColumnName("app_kind"); + + b.Property("CapabilityPurpose") + .HasColumnType("INTEGER") + .HasColumnName("capability_purpose"); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("correlation_id"); + + b.Property("EntraObjectId") + .HasColumnType("TEXT") + .HasColumnName("entra_object_id"); + + b.Property("GrantDigest") + .HasColumnType("TEXT") + .HasColumnName("grant_digest"); + + b.Property("OccurredAt") + .HasColumnType("TEXT") + .HasColumnName("occurred_at"); + + b.Property("Outcome") + .HasColumnType("INTEGER") + .HasColumnName("outcome"); + + b.Property("ReasonCode") + .HasColumnType("INTEGER") + .HasColumnName("reason_code"); + + b.Property("ResourceId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("resource_id"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAt"); + + b.ToTable("github_audit_records", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubAuthorizationRecord", b => + { + b.Property("State") + .HasColumnType("TEXT") + .HasColumnName("state"); + + b.Property("AppKind") + .HasColumnType("INTEGER") + .HasColumnName("app_kind"); + + b.Property("BrowserSessionId") + .HasColumnType("TEXT") + .HasColumnName("browser_session_id"); + + b.Property("CallbackCookieHash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("callback_cookie_hash"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("EntraObjectId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("entra_object_id"); + + b.Property("ExpiresAtUnixMilliseconds") + .HasColumnType("INTEGER") + .HasColumnName("expires_at_unix_ms"); + + b.Property("ExternalTransactionId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("external_transaction_id"); + + b.Property("PkceVerifierProtected") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pkce_verifier_protected"); + + b.Property("ProjectId") + .HasColumnType("TEXT") + .HasColumnName("project_id"); + + b.Property("Purpose") + .HasColumnType("INTEGER") + .HasColumnName("purpose"); + + b.Property("ReturnRouteKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("return_route_key"); + + b.Property("Status") + .HasColumnType("INTEGER") + .HasColumnName("status"); + + b.HasKey("State"); + + b.HasIndex("ExpiresAtUnixMilliseconds"); + + b.HasIndex("ExternalTransactionId") + .IsUnique(); + + b.HasIndex("ProjectId"); + + b.HasIndex("EntraObjectId", "State") + .IsUnique(); + + b.ToTable("github_authorizations", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubInstallationRecord", b => + { + b.Property("InstallationId") + .HasColumnType("INTEGER") + .HasColumnName("installation_id"); + + b.Property("AppKind") + .HasColumnType("INTEGER") + .HasColumnName("app_kind"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ProjectId") + .HasColumnType("TEXT") + .HasColumnName("project_id"); + + b.Property("RevokedAt") + .HasColumnType("TEXT") + .HasColumnName("revoked_at"); + + b.HasKey("InstallationId"); + + b.HasIndex("ProjectId"); + + b.ToTable("github_installations", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubLifecycleDeliveryRecord", b => + { + b.Property("DeliveryId") + .HasColumnType("TEXT") + .HasColumnName("delivery_id"); + + b.Property("EventName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("event_name"); + + b.Property("InstallationId") + .HasColumnType("INTEGER") + .HasColumnName("installation_id"); + + b.Property("ReceivedAt") + .HasColumnType("TEXT") + .HasColumnName("received_at"); + + b.Property("RepositoryId") + .HasColumnType("INTEGER") + .HasColumnName("repository_id"); + + b.HasKey("DeliveryId"); + + b.ToTable("github_lifecycle_deliveries", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubRepositoryGrantRecord", b => + { + b.Property("InstallationId") + .HasColumnType("INTEGER") + .HasColumnName("installation_id"); + + b.Property("RepositoryId") + .HasColumnType("INTEGER") + .HasColumnName("repository_id"); + + b.Property("FullNameDisplay") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("full_name_display"); + + b.Property("GrantedAt") + .HasColumnType("TEXT") + .HasColumnName("granted_at"); + + b.Property("PermissionDigest") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("permission_digest"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("project_id"); + + b.Property("RevokedAt") + .HasColumnType("TEXT") + .HasColumnName("revoked_at"); + + b.HasKey("InstallationId", "RepositoryId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("InstallationId", "RepositoryId") + .IsUnique(); + + b.ToTable("github_repository_grants", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubRepositorySelectionCodeRecord", b => + { + b.Property("CodeHash") + .HasColumnType("TEXT") + .HasColumnName("code_hash"); + + b.Property("ConsumedAtUnixMilliseconds") + .HasColumnType("INTEGER") + .HasColumnName("consumed_at_unix_ms"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("EntraObjectId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("entra_object_id"); + + b.Property("ExpiresAtUnixMilliseconds") + .HasColumnType("INTEGER") + .HasColumnName("expires_at_unix_ms"); + + b.Property("RepoAppAuthorizationId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("repo_app_authorization_id"); + + b.Property("RepositoryId") + .HasColumnType("INTEGER") + .HasColumnName("repository_id"); + + b.HasKey("CodeHash"); + + b.HasIndex("ExpiresAtUnixMilliseconds"); + + b.HasIndex("EntraObjectId", "ExpiresAtUnixMilliseconds"); + + b.ToTable("github_repository_selection_codes", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.IntegrationBuildLockRecord", b => + { + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("AcquiredAt") + .HasColumnType("TEXT"); + + b.Property("OwnerPodId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ProjectId"); + + b.ToTable("IntegrationBuildLocks"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.MarketplaceCopilotCapabilityRecord", b => + { + b.Property("CapabilityRef") + .HasColumnType("TEXT") + .HasColumnName("capability_ref"); + + b.Property("ClaimLeaseExpiresAt") + .HasColumnType("TEXT") + .HasColumnName("claim_lease_expires_at"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT") + .HasColumnName("consumed_at"); + + b.Property("CredentialReference") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("credential_reference"); + + b.Property("CredentialVersion") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("credential_version"); + + b.Property("EntraObjectId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("entra_object_id"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT") + .HasColumnName("expires_at"); + + b.Property("GrantDigest") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("grant_digest"); + + b.Property("IssuedAt") + .HasColumnType("TEXT") + .HasColumnName("issued_at"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("project_id"); + + b.Property("Purpose") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("purpose"); + + b.Property("SourceBindingId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_binding_id"); + + b.HasKey("CapabilityRef"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("IX_marketplace_copilot_capabilities_expiry_cleanup"); + + b.HasIndex("ProjectId", "EntraObjectId", "ExpiresAt") + .HasDatabaseName("IX_marketplace_copilot_capabilities_expiry"); + + b.ToTable("marketplace_copilot_capabilities", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.OutcomeSpec", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowTaskPromotion") + .HasColumnType("INTEGER"); + + b.Property("Assumptions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ClarifyingQuestions") + .HasColumnType("TEXT"); + + b.Property("ConfirmedBy") + .HasColumnType("TEXT"); + + b.Property("CoordinatorRunId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DesiredOutcome") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Goal") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "CoordinatorRunId"); + + b.ToTable("OutcomeSpecs"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.PlatformDefaultCopilotBindingRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BoundAt") + .HasColumnType("TEXT") + .HasColumnName("bound_at"); + + b.Property("CredentialReference") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("credential_reference"); + + b.Property("CredentialVersion") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("credential_version"); + + b.Property("DeactivatedAt") + .HasColumnType("TEXT") + .HasColumnName("deactivated_at"); + + b.Property("EntraObjectId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("entra_object_id"); + + b.Property("GrantDigest") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("grant_digest"); + + b.Property("Status") + .HasColumnType("INTEGER") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("platform_default_copilot_bindings", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.ProjectCopilotBindingRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BoundAt") + .HasColumnType("TEXT") + .HasColumnName("bound_at"); + + b.Property("CredentialReference") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("credential_reference"); + + b.Property("CredentialVersion") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("credential_version"); + + b.Property("DeactivatedAt") + .HasColumnType("TEXT") + .HasColumnName("deactivated_at"); + + b.Property("EntraObjectId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("entra_object_id"); + + b.Property("GrantDigest") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("grant_digest"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("project_id"); + + b.Property("Status") + .HasColumnType("INTEGER") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId") + .IsUnique() + .HasDatabaseName("UX_project_copilot_bindings_active_project") + .HasFilter("status = 0"); + + b.ToTable("project_copilot_bindings", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.ProjectRecord", b => + { + b.Property("ProjectId") + .HasColumnType("TEXT") + .HasColumnName("project_id"); + + b.Property("ActiveReviewPolicyName") + .HasColumnType("TEXT"); + + b.Property("AllowedWorkflowIds") + .HasColumnType("TEXT"); + + b.Property("BlueprintGenerationModel") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultBranch") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DefaultModelCopilot") + .HasColumnType("TEXT"); + + b.Property("DefaultModelFoundry") + .HasColumnType("TEXT"); + + b.Property("DefaultProvider") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DefaultWorkflowId") + .HasColumnType("TEXT"); + + b.Property("MaxReadyPerHeartbeat") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginKind") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutcomeSpecGenerationModel") + .HasColumnType("TEXT"); + + b.Property("Owner") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PickupAutoApproveTools") + .HasColumnType("INTEGER"); + + b.Property("PickupAutopilot") + .HasColumnType("INTEGER"); + + b.Property("PreviewApprovalTimeoutMinutes") + .HasColumnType("INTEGER"); + + b.Property("SandboxProfile") + .HasColumnType("TEXT"); + + b.Property("SourceBlueprintId") + .HasColumnType("TEXT"); + + b.Property("SourceBlueprintType") + .HasColumnType("TEXT"); + + b.Property("SourceRepository") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TeamRevision") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WebhookSecret") + .HasColumnType("TEXT"); + + b.Property("WorkflowGenerationModel") + .HasColumnType("TEXT"); + + b.Property("WorkingDirectory") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ProjectId"); + + b.ToTable("projects", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.RunAuthorshipCapability", b => + { + b.Property("RunId") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("run_id"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT") + .HasColumnName("expires_at"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("token_hash"); + + b.HasKey("RunId"); + + b.HasIndex("ExpiresAt"); + + b.ToTable("run_authorship_capabilities", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.RunGitHubCapabilitySnapshotRecord", b => + { + b.Property("SnapshotRef") + .HasColumnType("TEXT") + .HasColumnName("snapshot_ref"); + + b.Property("AppKind") + .HasColumnType("INTEGER") + .HasColumnName("app_kind"); + + b.Property("CapturedAt") + .HasColumnType("TEXT") + .HasColumnName("captured_at"); + + b.Property("CredentialReference") + .HasColumnType("TEXT") + .HasColumnName("credential_reference"); + + b.Property("CredentialVersion") + .HasColumnType("TEXT") + .HasColumnName("credential_version"); + + b.Property("EntraObjectId") + .HasColumnType("TEXT") + .HasColumnName("entra_object_id"); + + b.Property("GrantDigest") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("grant_digest"); + + b.Property("InstallationId") + .HasColumnType("INTEGER") + .HasColumnName("installation_id"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("project_id"); + + b.Property("Purpose") + .HasColumnType("INTEGER") + .HasColumnName("purpose"); + + b.Property("RepositoryId") + .HasColumnType("INTEGER") + .HasColumnName("repository_id"); + + b.Property("RunId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("run_id"); + + b.Property("SnapshotExpiresAt") + .HasColumnType("TEXT") + .HasColumnName("snapshot_expires_at"); + + b.Property("SourceAuthorizationId") + .HasColumnType("TEXT") + .HasColumnName("source_authorization_id"); + + b.Property("SourceBindingId") + .HasColumnType("TEXT") + .HasColumnName("source_binding_id"); + + b.Property("SourceKind") + .HasColumnType("INTEGER") + .HasColumnName("source_kind"); + + b.HasKey("SnapshotRef"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RunId", "Purpose") + .IsUnique() + .HasDatabaseName("UX_run_github_capability_snapshots_run_purpose"); + + b.ToTable("run_github_capability_snapshots", null, t => + { + t.HasCheckConstraint("CK_run_github_capability_snapshots_purpose_mapping", "(purpose = 0 AND app_kind = 0 AND source_kind = 0 AND entra_object_id IS NOT NULL AND source_authorization_id IS NOT NULL AND source_binding_id IS NULL AND installation_id IS NULL AND repository_id IS NOT NULL AND credential_reference IS NOT NULL AND credential_version IS NOT NULL)\nOR (purpose = 1 AND app_kind = 0 AND source_kind = 0 AND entra_object_id IS NOT NULL AND source_authorization_id IS NOT NULL AND source_binding_id IS NULL AND installation_id IS NULL AND repository_id IS NULL AND credential_reference IS NOT NULL AND credential_version IS NOT NULL)\nOR (purpose = 2 AND app_kind = 0 AND source_kind = 1 AND entra_object_id IS NULL AND source_authorization_id IS NULL AND source_binding_id IS NULL AND installation_id IS NOT NULL AND repository_id IS NOT NULL AND credential_reference IS NULL AND credential_version IS NULL)\nOR (purpose = 3 AND app_kind = 1 AND source_kind = 2 AND entra_object_id IS NULL AND source_authorization_id IS NULL AND source_binding_id IS NOT NULL AND installation_id IS NULL AND repository_id IS NULL AND credential_reference IS NOT NULL AND credential_version IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.RunGitHubIdentitySnapshotRecord", b => + { + b.Property("RunId") + .HasColumnType("TEXT") + .HasColumnName("run_id"); + + b.Property("AppKind") + .HasColumnType("INTEGER") + .HasColumnName("app_kind"); + + b.Property("CapturedAt") + .HasColumnType("TEXT") + .HasColumnName("captured_at"); + + b.Property("CredentialReference") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("credential_reference"); + + b.Property("CredentialVersion") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("credential_version"); + + b.Property("EntraObjectId") + .HasColumnType("TEXT") + .HasColumnName("entra_object_id"); + + b.Property("GrantDigest") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("grant_digest"); + + b.Property("InstallationId") + .HasColumnType("INTEGER") + .HasColumnName("installation_id"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("project_id"); + + b.Property("Purpose") + .HasColumnType("INTEGER") + .HasColumnName("purpose"); + + b.Property("RepositoryId") + .HasColumnType("INTEGER") + .HasColumnName("repository_id"); + + b.HasKey("RunId"); + + b.HasIndex("ProjectId"); + + b.ToTable("run_github_identity_snapshots", (string)null); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.SessionContext", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActiveIssues") + .HasColumnType("TEXT"); + + b.Property("EndedAt") + .HasColumnType("TEXT"); + + b.Property("FocusArea") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SerializedState") + .HasColumnType("TEXT"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "EndedAt"); + + b.HasIndex("ProjectId", "SessionId") + .IsUnique(); + + b.ToTable("SessionContexts"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.SteeringDirective", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionAttempt") + .HasColumnType("INTEGER"); + + b.Property("CoordinatorRunId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DecidedAction") + .HasColumnType("TEXT"); + + b.Property("ExecStartedAt") + .HasColumnType("TEXT"); + + b.Property("ExecutionAttempts") + .HasColumnType("INTEGER"); + + b.Property("Instruction") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RelayedAt") + .HasColumnType("TEXT"); + + b.Property("Severity") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TargetChildRunId") + .HasColumnType("TEXT"); + + b.Property("TargetScopeJson") + .HasColumnType("TEXT"); + + b.Property("TreeHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CoordinatorRunId", "Status"); + + b.ToTable("SteeringDirectives"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.SteeringRevisionExecution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActionAttempt") + .HasColumnType("INTEGER"); + + b.Property("CheckpointWatermark") + .HasColumnType("INTEGER"); + + b.Property("ConfirmedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EffectState") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RunId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SteeringDirectiveId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SteeringDirectiveId", "ActionAttempt", "RunId") + .IsUnique(); + + b.ToTable("SteeringRevisionExecutions"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.Subtask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgentCharter") + .HasColumnType("TEXT"); + + b.Property("AssignedAgent") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ChildRunId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeclaredOutputPathsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("InfrastructureRetryCount") + .HasColumnType("INTEGER"); + + b.Property("InfrastructureRetryEligibleAt") + .HasColumnType("TEXT"); + + b.Property("IsolationStrategy") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastResetAttempt") + .HasColumnType("INTEGER"); + + b.Property("LastResetDirectiveId") + .HasColumnType("INTEGER"); + + b.Property("LockedOutAgents") + .HasColumnType("TEXT"); + + b.Property("Phase") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PriorChildRunId") + .HasColumnType("TEXT"); + + b.Property("RecoveryAttempts") + .HasColumnType("INTEGER"); + + b.Property("RecoveryGuidance") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SelectedModelId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SteeringRetentionUntil") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WorkPlanId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WorkPlanId"); + + b.ToTable("Subtasks"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.SubtaskDependency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DependsOnSubtaskId") + .HasColumnType("INTEGER"); + + b.Property("SubtaskId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DependsOnSubtaskId"); + + b.HasIndex("SubtaskId"); + + b.ToTable("SubtaskDependencies"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.WorkPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssemblyStage") + .HasColumnType("TEXT"); + + b.Property("AssemblyStartedAt") + .HasColumnType("TEXT"); + + b.Property("AssemblyStatusReason") + .HasColumnType("TEXT"); + + b.Property("AssemblyTerminalStage") + .HasColumnType("TEXT"); + + b.Property("CoordinatorPodId") + .HasColumnType("TEXT"); + + b.Property("CoordinatorRunId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("HumanReviewRoundTrips") + .HasColumnType("INTEGER"); + + b.Property("IntegrationBranch") + .HasColumnType("TEXT"); + + b.Property("IsolationSummary") + .HasColumnType("TEXT"); + + b.Property("OutcomeSpecId") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SteeringIterations") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WorkflowId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CoordinatorRunId"); + + b.HasIndex("OutcomeSpecId"); + + b.ToTable("WorkPlans"); + }); + + modelBuilder.Entity("Agentweaver.Api.Runs.CoordinatorDeferredDecisionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DecisionJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RunId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RunId") + .IsUnique(); + + b.ToTable("DeferredDecisions"); + }); + + modelBuilder.Entity("Agentweaver.Api.Runs.PendingRequestRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUser") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RequestJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RunId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("RunId") + .IsUnique(); + + b.ToTable("PendingRequests"); + }); + + modelBuilder.Entity("Agentweaver.Api.Runs.RunEventRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RunId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RunId"); + + b.HasIndex("RunId", "Sequence") + .IsUnique(); + + b.ToTable("RunEvents"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.AutomationActivationRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_automation_activations_projects_project_id"); + + b.HasOne("Agentweaver.Api.Memory.GitHubRepositoryGrantRecord", null) + .WithMany() + .HasForeignKey("InstallationId", "RepositoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_automation_activations_repository_grants_installation_id_repository_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.AutomationInvocationRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.AutomationActivationRecord", null) + .WithMany() + .HasForeignKey("ActivationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_automation_invocations_activations_activation_id"); + + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_automation_invocations_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.Decision", b => + { + b.HasOne("Agentweaver.Api.Memory.Decision", null) + .WithMany() + .HasForeignKey("SupersededById"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.DecisionInboxEntry", b => + { + b.HasOne("Agentweaver.Api.Memory.Decision", null) + .WithMany() + .HasForeignKey("DecisionId"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubAuthorizationRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_github_authorizations_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubInstallationRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_github_installations_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.GitHubRepositoryGrantRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.GitHubInstallationRecord", null) + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_github_repository_grants_installations_installation_id"); + + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_github_repository_grants_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.MarketplaceCopilotCapabilityRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_marketplace_copilot_capabilities_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.ProjectCopilotBindingRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_project_copilot_bindings_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.RunGitHubCapabilitySnapshotRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_run_github_capability_snapshots_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.RunGitHubIdentitySnapshotRecord", b => + { + b.HasOne("Agentweaver.Api.Memory.ProjectRecord", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_run_github_identity_snapshots_projects_project_id"); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.Subtask", b => + { + b.HasOne("Agentweaver.Api.Memory.WorkPlan", null) + .WithMany() + .HasForeignKey("WorkPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.SubtaskDependency", b => + { + b.HasOne("Agentweaver.Api.Memory.Subtask", null) + .WithMany() + .HasForeignKey("DependsOnSubtaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Agentweaver.Api.Memory.Subtask", null) + .WithMany() + .HasForeignKey("SubtaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Agentweaver.Api.Memory.WorkPlan", b => + { + b.HasOne("Agentweaver.Api.Memory.OutcomeSpec", null) + .WithMany() + .HasForeignKey("OutcomeSpecId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/apps/Agentweaver.Api/Migrations/20260831164652_AddPlatformDefaultCopilotBinding.cs b/apps/Agentweaver.Api/Migrations/20260831164652_AddPlatformDefaultCopilotBinding.cs new file mode 100644 index 000000000..f853968a4 --- /dev/null +++ b/apps/Agentweaver.Api/Migrations/20260831164652_AddPlatformDefaultCopilotBinding.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Agentweaver.Api.Migrations +{ + /// + public partial class AddPlatformDefaultCopilotBinding : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "platform_default_copilot_bindings", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + entra_object_id = table.Column(type: "TEXT", nullable: false), + credential_reference = table.Column(type: "TEXT", nullable: false), + credential_version = table.Column(type: "TEXT", nullable: false), + grant_digest = table.Column(type: "TEXT", nullable: false), + status = table.Column(type: "INTEGER", nullable: false), + bound_at = table.Column(type: "TEXT", nullable: false), + deactivated_at = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_platform_default_copilot_bindings", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "platform_default_copilot_bindings"); + } + } +} diff --git a/apps/Agentweaver.Api/Migrations/MemoryDbContextModelSnapshot.cs b/apps/Agentweaver.Api/Migrations/MemoryDbContextModelSnapshot.cs index 42f5f84be..63077f413 100644 --- a/apps/Agentweaver.Api/Migrations/MemoryDbContextModelSnapshot.cs +++ b/apps/Agentweaver.Api/Migrations/MemoryDbContextModelSnapshot.cs @@ -303,10 +303,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT") .HasColumnName("backlog_task_id"); - b.Property("PendingBacklogTaskId") - .HasColumnType("TEXT") - .HasColumnName("pending_backlog_task_id"); - b.Property("CompletedAt") .HasColumnType("TEXT") .HasColumnName("completed_at"); @@ -332,6 +328,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("INTEGER") .HasColumnName("outcome"); + b.Property("PendingBacklogTaskId") + .HasColumnType("TEXT") + .HasColumnName("pending_backlog_task_id"); + b.Property("ProjectId") .IsRequired() .HasColumnType("TEXT") @@ -1016,6 +1016,49 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("OutcomeSpecs"); }); + modelBuilder.Entity("Agentweaver.Api.Memory.PlatformDefaultCopilotBindingRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BoundAt") + .HasColumnType("TEXT") + .HasColumnName("bound_at"); + + b.Property("CredentialReference") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("credential_reference"); + + b.Property("CredentialVersion") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("credential_version"); + + b.Property("DeactivatedAt") + .HasColumnType("TEXT") + .HasColumnName("deactivated_at"); + + b.Property("EntraObjectId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("entra_object_id"); + + b.Property("GrantDigest") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("grant_digest"); + + b.Property("Status") + .HasColumnType("INTEGER") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("platform_default_copilot_bindings", (string)null); + }); + modelBuilder.Entity("Agentweaver.Api.Memory.ProjectCopilotBindingRecord", b => { b.Property("Id") diff --git a/apps/Agentweaver.Api/Program.cs b/apps/Agentweaver.Api/Program.cs index 104f2a5e5..5becdf111 100644 --- a/apps/Agentweaver.Api/Program.cs +++ b/apps/Agentweaver.Api/Program.cs @@ -1056,6 +1056,7 @@ await memoryDb.Database.ExecuteSqlRawAsync(""" app.MapTeamEndpoints(); app.MapAuthEndpoints(); app.MapByokProviderSettingsEndpoints(); + app.MapPlatformDefaultCopilotBindingEndpoints(); app.MapGitHubRepositorySelectionEndpoints(); app.MapDecisionsEndpoints(); app.MapMemoryEndpoints(); diff --git a/apps/Agentweaver.Api/Tools/SqliteToPostgresMigrator.cs b/apps/Agentweaver.Api/Tools/SqliteToPostgresMigrator.cs index 340ab1698..5b72d5f88 100644 --- a/apps/Agentweaver.Api/Tools/SqliteToPostgresMigrator.cs +++ b/apps/Agentweaver.Api/Tools/SqliteToPostgresMigrator.cs @@ -70,6 +70,7 @@ private async Task MigrateGitHubConnectionsRecordsAsync(string memoryDbPath, Mem List installations; List grants; List bindings; + List platformBindings; List activations; List invocations; List lifecycleDeliveries; @@ -83,6 +84,7 @@ private async Task MigrateGitHubConnectionsRecordsAsync(string memoryDbPath, Mem installations = await source.GitHubInstallations.AsNoTracking().ToListAsync(ct).ConfigureAwait(false); grants = await source.GitHubRepositoryGrants.AsNoTracking().ToListAsync(ct).ConfigureAwait(false); bindings = await source.ProjectCopilotBindings.AsNoTracking().ToListAsync(ct).ConfigureAwait(false); + platformBindings = await source.PlatformDefaultCopilotBindings.AsNoTracking().ToListAsync(ct).ConfigureAwait(false); activations = await source.AutomationActivations.AsNoTracking().ToListAsync(ct).ConfigureAwait(false); invocations = await source.AutomationInvocations.AsNoTracking().ToListAsync(ct).ConfigureAwait(false); lifecycleDeliveries = await source.GitHubLifecycleDeliveries.AsNoTracking().ToListAsync(ct).ConfigureAwait(false); @@ -100,7 +102,7 @@ private async Task MigrateGitHubConnectionsRecordsAsync(string memoryDbPath, Mem return; } - if (authorizations.Count + installations.Count + grants.Count + bindings.Count + activations.Count + + if (authorizations.Count + installations.Count + grants.Count + bindings.Count + platformBindings.Count + activations.Count + invocations.Count + lifecycleDeliveries.Count + snapshots.Count + capabilitySnapshots.Count + appAuthorizations.Count + audits.Count == 0) return; @@ -139,6 +141,9 @@ private async Task MigrateGitHubConnectionsRecordsAsync(string memoryDbPath, Mem foreach (var item in bindings) if (!await destination.ProjectCopilotBindings.AnyAsync(x => x.Id == item.Id, ct).ConfigureAwait(false)) destination.ProjectCopilotBindings.Add(item); + foreach (var item in platformBindings) + if (!await destination.PlatformDefaultCopilotBindings.AnyAsync(x => x.Id == item.Id, ct).ConfigureAwait(false)) + destination.PlatformDefaultCopilotBindings.Add(item); foreach (var item in activations) if (!await destination.AutomationActivations.AnyAsync(x => x.Id == item.Id, ct).ConfigureAwait(false)) destination.AutomationActivations.Add(item); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d84665de2..186018624 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -34,10 +34,17 @@ import { WorkflowsPage } from './pages/WorkflowsPage'; import { WorkspacePage } from './pages/WorkspacePage'; import { CoordinatorRunRoute } from './routes/CoordinatorRunRoute'; import { AssistantRoute } from './routes/AssistantRoute'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { BrowserRouter, Navigate, Route, Routes, useParams } from 'react-router-dom'; +import { Body, PageContainer, PageHeader, PageSection } from './components/ui'; -function Shell({ isPlatformAdmin }: { isPlatformAdmin: boolean }) { +function Shell({ + isPlatformAdmin, + onAiConfigurationChanged, +}: { + isPlatformAdmin: boolean; + onAiConfigurationChanged?: () => void; +}) { return ( @@ -49,7 +56,9 @@ function Shell({ isPlatformAdmin }: { isPlatformAdmin: boolean }) { } /> : } + element={isPlatformAdmin + ? + : } /> {/* Legacy operator-dock bookmark (#346) — the dock is retired; route old links straight through the assistant page. */} @@ -134,40 +143,64 @@ function describeSessionCheckError(err: unknown): string | null { function AuthGate() { const [authChecked, setAuthChecked] = useState(false); const [signedIn, setSignedIn] = useState(false); + const [hasPlatformAccess, setHasPlatformAccess] = useState(false); const [isPlatformAdmin, setIsPlatformAdmin] = useState(false); + const [aiConfigured, setAiConfigured] = useState(true); const [sessionError, setSessionError] = useState(null); - useEffect(() => { - let cancelled = false; - captureSessionAuthFromUrl() - .then(() => apiClient.getServerInfo()) - .then(async () => { - if (cancelled) return; - const session = await apiClient.getAuthSession(); - if (cancelled) return; - setSessionError(null); - if (!session.authenticated) { - clearSessionAuth(); - setSignedIn(false); - setIsPlatformAdmin(false); - setAuthChecked(true); - return; - } - setIsPlatformAdmin(session.platform_roles.includes('PlatformAdmin')); - setSignedIn(true); - setAuthChecked(true); - }) - .catch((err: unknown) => { - if (cancelled) return; + const runSessionCheck = useCallback(async (cancelledRef?: { cancelled: boolean }) => { + setAuthChecked(false); + setSessionError(null); + setAiConfigured(true); + setSignedIn(false); + setHasPlatformAccess(false); + setIsPlatformAdmin(false); + + try { + await captureSessionAuthFromUrl(); + await apiClient.getServerInfo(); + if (cancelledRef?.cancelled) return; + const session = await apiClient.getAuthSession(); + if (cancelledRef?.cancelled) return; + setSessionError(null); + if (!session.authenticated) { clearSessionAuth(); setSignedIn(false); + setHasPlatformAccess(false); setIsPlatformAdmin(false); - setSessionError(describeSessionCheckError(err)); + setAiConfigured(true); setAuthChecked(true); - }); - return () => { cancelled = true; }; + return; + } + const roles = session.platform_roles; + setHasPlatformAccess(roles.length > 0); + setIsPlatformAdmin(roles.includes('PlatformAdmin')); + setAiConfigured(session.ai_configured); + setSignedIn(true); + setAuthChecked(true); + } catch (err: unknown) { + if (cancelledRef?.cancelled) return; + clearSessionAuth(); + setSignedIn(false); + setHasPlatformAccess(false); + setIsPlatformAdmin(false); + setAiConfigured(true); + setSessionError(describeSessionCheckError(err)); + setAuthChecked(true); + } }, []); + useEffect(() => { + const cancelledRef = { cancelled: false }; + const timer = window.setTimeout(() => { + void runSessionCheck(cancelledRef); + }, 0); + return () => { + cancelledRef.cancelled = true; + window.clearTimeout(timer); + }; + }, [runSessionCheck]); + if (!authChecked) { return ; } @@ -176,7 +209,53 @@ function AuthGate() { return ; } - return ; + if (!hasPlatformAccess) { + return ( + + + + + Ask a Platform Admin to assign you an Agentweaver platform role in Microsoft Entra ID, + then refresh this page. + + + + ); + } + + if (!aiConfigured) { + if (isPlatformAdmin) { + return ( + + { void runSessionCheck(); }} />} + /> + } /> + + ); + } + + return ( + + + + + An administrator needs to configure an AI provider before Agentweaver can be used. + Please contact your administrator. + + + + ); + } + + return { void runSessionCheck(); }} />; } function App() { diff --git a/apps/web/src/__tests__/App.test.tsx b/apps/web/src/__tests__/App.test.tsx new file mode 100644 index 000000000..39c82c55b --- /dev/null +++ b/apps/web/src/__tests__/App.test.tsx @@ -0,0 +1,187 @@ +import App from '../App'; +import { apiClient } from '../api/apiClient'; +import { ApiError } from '../api/client'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const retrySpy = vi.fn(); + +vi.mock('../api/apiClient', () => ({ + apiClient: { + getServerInfo: vi.fn(), + getAuthSession: vi.fn(), + }, +})); + +vi.mock('../config', () => ({ + captureSessionAuthFromUrl: vi.fn().mockResolvedValue(undefined), + clearSessionAuth: vi.fn(), +})); + +vi.mock('../components/shell/AppShell', () => ({ + AppShell: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock('../pages/PlatformSettingsPage', () => ({ + PlatformSettingsPage: ({ + setupRequired, + onRetryAccess, + }: { + setupRequired?: boolean; + onRetryAccess?: () => void; + }) => ( +
+
Platform settings
+
{setupRequired ? 'Setup required' : 'Setup optional'}
+ +
+ ), +})); + +vi.mock('../pages/OverviewPage', () => ({ OverviewPage: () =>
Overview page
})); +vi.mock('../pages/SignInPage', () => ({ + SignInPage: ({ sessionError }: { sessionError?: string | null }) =>
{sessionError ?? 'Sign in'}
, + SignInPageLoading: () =>
Loading
, +})); +vi.mock('../pages/CastingWizardPage', () => ({ CastingWizardPage: () => null })); +vi.mock('../pages/ClusterPage', () => ({ ClusterPage: () => null })); +vi.mock('../pages/DashboardPage', () => ({ DashboardPage: () => null })); +vi.mock('../pages/DiagnosticsPage', () => ({ DiagnosticsPage: () => null })); +vi.mock('../pages/FlowPage', () => ({ FlowPage: () => null })); +vi.mock('../pages/AgentMemoryPage', () => ({ AgentMemoryPage: () => null })); +vi.mock('../pages/HeartbeatPage', () => ({ HeartbeatPage: () => null })); +vi.mock('../pages/MemoriesPage', () => ({ MemoriesPage: () => null })); +vi.mock('../pages/observability/ObservabilityAgentsPage', () => ({ ObservabilityAgentsPage: () => null })); +vi.mock('../pages/observability/ObservabilityOverviewPage', () => ({ ObservabilityOverviewPage: () => null })); +vi.mock('../pages/observability/ObservabilityRedirectPage', () => ({ ObservabilityRedirectPage: () => null })); +vi.mock('../pages/observability/ObservabilityTracesPage', () => ({ ObservabilityTracesPage: () => null })); +vi.mock('../pages/OrchestrationsPage', () => ({ OrchestrationsPage: () => null })); +vi.mock('../pages/ProjectGalleryPage', () => ({ ProjectGalleryPage: () => null })); +vi.mock('../pages/ProjectPage', () => ({ ProjectPage: () => null })); +vi.mock('../pages/ProjectSettingsPage', () => ({ ProjectSettingsPage: () => null })); +vi.mock('../pages/SessionsPage', () => ({ SessionsPage: () => null })); +vi.mock('../pages/SettingsPage', () => ({ SettingsPage: () => null })); +vi.mock('../pages/SkillsPage', () => ({ SkillsPage: () => null })); +vi.mock('../pages/TeamPage', () => ({ TeamPage: () => null })); +vi.mock('../pages/WorkflowsPage', () => ({ WorkflowsPage: () => null })); +vi.mock('../pages/WorkspacePage', () => ({ WorkspacePage: () => null })); +vi.mock('../routes/CoordinatorRunRoute', () => ({ CoordinatorRunRoute: () => null })); +vi.mock('../routes/AssistantRoute', () => ({ AssistantRoute: () => null })); + +describe('App auth gate', () => { + beforeEach(() => { + cleanup(); + retrySpy.mockReset(); + window.history.pushState({}, '', '/projects/proj-1'); + vi.mocked(apiClient.getServerInfo).mockResolvedValue({ + data_directory: 'C:\\data', + }); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('shows sign-in instead of the AI lockout when the session check returns 401', async () => { + vi.mocked(apiClient.getAuthSession).mockRejectedValue(new ApiError(401, '{"error":"unauthorized"}')); + + render(); + + expect(await screen.findByText('Sign in')).toBeDefined(); + expect(screen.queryByText(/AI setup required/)).toBeNull(); + }); + + it('redirects platform admins to platform settings when AI is not configured', async () => { + vi.mocked(apiClient.getAuthSession).mockResolvedValue({ + authenticated: true, + auth_mode: 'entra', + display_name: 'Admin', + email: 'admin@example.com', + login: 'admin', + avatar_url: null, + entra_object_id: 'entra-admin', + platform_roles: ['PlatformAdmin'], + ai_configured: false, + }); + + render(); + + expect(await screen.findByText('Platform settings')).toBeDefined(); + expect(screen.getByText('Setup required')).toBeDefined(); + await waitFor(() => expect(window.location.pathname).toBe('/platform-settings')); + }); + + it('lets a platform admin retry the AI configuration check after fixing setup', async () => { + vi.mocked(apiClient.getAuthSession) + .mockResolvedValueOnce({ + authenticated: true, + auth_mode: 'entra', + display_name: 'Admin', + email: 'admin@example.com', + login: 'admin', + avatar_url: null, + entra_object_id: 'entra-admin', + platform_roles: ['PlatformAdmin'], + ai_configured: false, + }) + .mockResolvedValueOnce({ + authenticated: true, + auth_mode: 'entra', + display_name: 'Admin', + email: 'admin@example.com', + login: 'admin', + avatar_url: null, + entra_object_id: 'entra-admin', + platform_roles: ['PlatformAdmin'], + ai_configured: true, + }); + + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Retry access' })); + + await waitFor(() => expect(retrySpy).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByTestId('app-shell')).toBeDefined()); + }); + + it('shows a non-admin lockout message when AI is not configured', async () => { + vi.mocked(apiClient.getAuthSession).mockResolvedValue({ + authenticated: true, + auth_mode: 'entra', + display_name: 'Member', + email: 'member@example.com', + login: 'member', + avatar_url: null, + entra_object_id: 'entra-member', + platform_roles: ['Contributor'], + ai_configured: false, + }); + + render(); + + expect(await screen.findByText(/An administrator needs to configure an AI provider/)).toBeDefined(); + expect(screen.queryByText('Platform settings')).toBeNull(); + }); + + it('shows access denied instead of the AI lockout when no platform role is assigned', async () => { + vi.mocked(apiClient.getAuthSession).mockResolvedValue({ + authenticated: true, + auth_mode: 'entra', + display_name: 'No Role', + email: 'norole@example.com', + login: 'norole', + avatar_url: null, + entra_object_id: 'entra-norole', + platform_roles: [], + ai_configured: false, + }); + + render(); + + expect(await screen.findByText('Access denied')).toBeDefined(); + expect(screen.getByText(/no Agentweaver platform role is assigned/i)).toBeDefined(); + expect(screen.queryByText(/AI setup required/)).toBeNull(); + }); +}); diff --git a/apps/web/src/__tests__/AppShell.test.tsx b/apps/web/src/__tests__/AppShell.test.tsx index 3fd72c713..b475d7953 100644 --- a/apps/web/src/__tests__/AppShell.test.tsx +++ b/apps/web/src/__tests__/AppShell.test.tsx @@ -104,6 +104,7 @@ beforeEach(() => { avatar_url: 'https://example.com/sabbour.png', entra_object_id: 'entra-1', platform_roles: ['PlatformAdmin'], + ai_configured: true, } as never); vi.mocked(apiClient.getProjectAccessOverview).mockResolvedValue({ auth_mode: 'entra', @@ -286,6 +287,7 @@ describe('AppShell navigation', () => { avatar_url: 'https://example.com/sabbour.png', entra_object_id: 'entra-1', platform_roles: ['PlatformAdmin'], + ai_configured: true, } as never); renderShellAt('/overview'); diff --git a/apps/web/src/__tests__/GitHubIdentityBadge.test.tsx b/apps/web/src/__tests__/GitHubIdentityBadge.test.tsx index 97d321e65..3d24bbe6d 100644 --- a/apps/web/src/__tests__/GitHubIdentityBadge.test.tsx +++ b/apps/web/src/__tests__/GitHubIdentityBadge.test.tsx @@ -32,6 +32,7 @@ beforeEach(() => { avatar_url: 'https://example.com/ada.png', entra_object_id: 'entra-1', platform_roles: [], + ai_configured: true, } as never); vi.mocked(apiClient.getProjectCopilotConnection).mockResolvedValue({ status: 'connected', diff --git a/apps/web/src/__tests__/PlatformSettingsPage.test.tsx b/apps/web/src/__tests__/PlatformSettingsPage.test.tsx index e215f6347..8b4b5c583 100644 --- a/apps/web/src/__tests__/PlatformSettingsPage.test.tsx +++ b/apps/web/src/__tests__/PlatformSettingsPage.test.tsx @@ -3,20 +3,26 @@ import { AzureFluentProvider } from '../copilot-fluent-system'; import { PlatformSettingsPage } from '../pages/PlatformSettingsPage'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; vi.mock('../api/apiClient', () => ({ apiClient: { getByokProviderConfig: vi.fn(), setByokProviderConfig: vi.fn(), clearByokProviderConfig: vi.fn(), + getPlatformDefaultCopilotConnection: vi.fn(), + beginPlatformDefaultCopilotAuthorization: vi.fn(), + disconnectPlatformDefaultCopilotConnection: vi.fn(), }, })); -function renderPage() { +function renderPage(initialEntry = '/platform-settings') { render( - - - , + + + + + , ); } @@ -25,6 +31,13 @@ describe('PlatformSettingsPage', () => { vi.mocked(apiClient.getByokProviderConfig).mockReset(); vi.mocked(apiClient.setByokProviderConfig).mockReset(); vi.mocked(apiClient.clearByokProviderConfig).mockReset(); + vi.mocked(apiClient.getPlatformDefaultCopilotConnection).mockReset(); + vi.mocked(apiClient.beginPlatformDefaultCopilotAuthorization).mockReset(); + vi.mocked(apiClient.disconnectPlatformDefaultCopilotConnection).mockReset(); + vi.mocked(apiClient.getPlatformDefaultCopilotConnection).mockResolvedValue({ + connected: false, + github_login: null, + }); }); afterEach(() => { @@ -40,6 +53,7 @@ describe('PlatformSettingsPage', () => { /GitHub Copilot mode/, )) as HTMLInputElement; expect(copilotRadio.checked).toBe(true); + expect(await screen.findByText(/No platform-default GitHub Copilot account is connected yet/)).toBeDefined(); }); it('shows the BYOK form pre-filled when a custom key is already configured', async () => { @@ -87,6 +101,7 @@ describe('PlatformSettingsPage', () => { }); it('switches back to GitHub Copilot mode by clearing the saved configuration', async () => { + const onRetryAccess = vi.fn(); vi.mocked(apiClient.getByokProviderConfig).mockResolvedValue({ type: 'openai', base_url: 'https://api.example.com', @@ -94,11 +109,66 @@ describe('PlatformSettingsPage', () => { configured: true, }); vi.mocked(apiClient.clearByokProviderConfig).mockResolvedValue(undefined); - renderPage(); + render( + + + + + , + ); fireEvent.click(await screen.findByLabelText(/GitHub Copilot mode/)); fireEvent.click(screen.getByRole('button', { name: /Switch to GitHub Copilot mode/ })); await waitFor(() => expect(apiClient.clearByokProviderConfig).toHaveBeenCalled()); + expect(onRetryAccess).toHaveBeenCalled(); + }); + + it('starts the platform-default Copilot OAuth redirect', async () => { + const assign = vi.spyOn(window.location, 'assign').mockImplementation(() => {}); + vi.mocked(apiClient.getByokProviderConfig).mockResolvedValue(null); + vi.mocked(apiClient.beginPlatformDefaultCopilotAuthorization).mockResolvedValue({ + authorization_url: 'https://github.com/login/oauth/authorize?state=test', + transaction_id: 'txn', + expires_at: '2026-08-31T12:00:00Z', + }); + renderPage(); + + fireEvent.click(await screen.findByRole('button', { name: 'Connect GitHub Copilot' })); + + await waitFor(() => expect(apiClient.beginPlatformDefaultCopilotAuthorization).toHaveBeenCalled()); + expect(assign).toHaveBeenCalledWith('https://github.com/login/oauth/authorize?state=test'); + assign.mockRestore(); + }); + + it('shows the connected platform-default GitHub login and disconnects it', async () => { + const onRetryAccess = vi.fn(); + vi.mocked(apiClient.getByokProviderConfig).mockResolvedValue(null); + vi.mocked(apiClient.getPlatformDefaultCopilotConnection) + .mockResolvedValueOnce({ connected: true, github_login: 'octocat' }) + .mockResolvedValueOnce({ connected: false, github_login: null }); + vi.mocked(apiClient.disconnectPlatformDefaultCopilotConnection).mockResolvedValue(undefined); + render( + + + + + , + ); + + expect(await screen.findByText(/Connected GitHub login: @octocat/)).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })); + + await waitFor(() => expect(apiClient.disconnectPlatformDefaultCopilotConnection).toHaveBeenCalled()); + expect(await screen.findByText('Configuration saved.')).toBeDefined(); + expect(onRetryAccess).toHaveBeenCalled(); + }); + + it('shows the callback success notice without echoing the raw query value', async () => { + vi.mocked(apiClient.getByokProviderConfig).mockResolvedValue(null); + renderPage('/platform-settings?copilot_app_auth=success'); + + expect(await screen.findByText(/platform-default GitHub Copilot account is connected/i)).toBeDefined(); + expect(screen.queryByText('success')).toBeNull(); }); }); diff --git a/apps/web/src/__tests__/SettingsPage.test.tsx b/apps/web/src/__tests__/SettingsPage.test.tsx index bca3e8be8..417125310 100644 --- a/apps/web/src/__tests__/SettingsPage.test.tsx +++ b/apps/web/src/__tests__/SettingsPage.test.tsx @@ -34,6 +34,7 @@ beforeEach(() => { avatar_url: null, entra_object_id: 'entra-1', platform_roles: ['PlatformAdmin', 'ProjectCreator'], + ai_configured: true, } as never); }); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 3c274cce5..d4525f1b4 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -49,6 +49,7 @@ import type { PortForwardSessionDto, PagedRequestOptions, PagedResult, + PlatformDefaultCopilotConnection, Project, ProjectAccessOverview, ProjectCopilotConnection, @@ -535,6 +536,22 @@ export class AgentweaverApiClient { return this.request('DELETE', '/admin/byok-provider'); } + beginPlatformDefaultCopilotAuthorization(): Promise<{ + authorization_url: string; + transaction_id: string; + expires_at: string; + }> { + return this.request('POST', '/admin/platform-default-copilot/begin', {}); + } + + getPlatformDefaultCopilotConnection(): Promise { + return this.request('GET', '/admin/platform-default-copilot/status'); + } + + disconnectPlatformDefaultCopilotConnection(): Promise { + return this.request('POST', '/admin/platform-default-copilot/disconnect', {}); + } + beginRepoAppAuthorization(): Promise<{ authorization_url: string; transaction_id: string; expires_at: string }> { return this.request('POST', '/auth/github/repo-app/authorizations', {}); } diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 3f69cd05f..f3a487b88 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -458,6 +458,7 @@ export interface AuthSessionResponse { avatar_url?: string | null; entra_object_id?: string | null; platform_roles: string[]; + ai_configured: boolean; } export interface AuthConfigResponse { @@ -572,6 +573,11 @@ export interface ProjectCopilotConnection { github_login: string | null; } +export interface PlatformDefaultCopilotConnection { + connected: boolean; + github_login: string | null; +} + // --- Casting / Team types --- diff --git a/apps/web/src/pages/PlatformSettingsPage.tsx b/apps/web/src/pages/PlatformSettingsPage.tsx index e85bb01cc..b9a3f7934 100644 --- a/apps/web/src/pages/PlatformSettingsPage.tsx +++ b/apps/web/src/pages/PlatformSettingsPage.tsx @@ -14,8 +14,13 @@ import { } from '@fluentui/react-components'; import { apiClient } from '../api/apiClient'; import { formatApiErrorMessage } from '../api/errors'; -import type { ByokProviderConfig, ByokProviderType } from '../api/types'; +import type { + ByokProviderConfig, + ByokProviderType, + PlatformDefaultCopilotConnection, +} from '../api/types'; import { Body, PageContainer, PageHeader, PageSection } from '../components/ui'; +import { useSearchParams } from 'react-router-dom'; type AiMode = 'copilot' | 'byok'; @@ -31,6 +36,11 @@ const useStyles = makeStyles({ alignItems: 'center', gap: tokens.spacingHorizontalS, }, + stack: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + }, }); const PROVIDER_LABELS: Record = { @@ -39,12 +49,56 @@ const PROVIDER_LABELS: Record = { anthropic: 'Anthropic', }; -export function PlatformSettingsPage() { +const PLATFORM_COPILOT_AUTH_RESULTS = { + success: { + intent: 'success', + message: 'The platform-default GitHub Copilot account is connected.', + }, + human_entra_subject_required: { + intent: 'warning', + message: 'Connect GitHub Copilot while signed in with your work account.', + }, + platform_admin_required: { + intent: 'warning', + message: 'Only a Platform Admin can connect the platform-default GitHub Copilot account.', + }, + authorization_transaction_invalid: { + intent: 'error', + message: 'The GitHub Copilot connection could not be completed. Start a new connection from Platform settings.', + }, + authorization_transaction_consumed: { + intent: 'error', + message: 'This GitHub Copilot connection has already been used. Start a new connection from Platform settings.', + }, + github_binding_unavailable: { + intent: 'error', + message: 'The GitHub Copilot connection is currently unavailable. Try again later.', + }, +} as const; + +type PlatformCopilotAuthorizationResultCode = keyof typeof PLATFORM_COPILOT_AUTH_RESULTS; + +function isPlatformCopilotAuthorizationResultCode( + value: string | null, +): value is PlatformCopilotAuthorizationResultCode { + return value !== null && Object.hasOwn(PLATFORM_COPILOT_AUTH_RESULTS, value); +} + +export function PlatformSettingsPage({ + setupRequired = false, + onRetryAccess, +}: { + setupRequired?: boolean; + onRetryAccess?: () => void; +}) { const styles = useStyles(); + const [searchParams, setSearchParams] = useSearchParams(); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [existingConfig, setExistingConfig] = useState(null); const [mode, setMode] = useState('copilot'); + const [platformCopilotConnection, setPlatformCopilotConnection] = useState(null); + const [platformCopilotError, setPlatformCopilotError] = useState(null); const [providerType, setProviderType] = useState('openai'); const [baseUrl, setBaseUrl] = useState(''); @@ -52,8 +106,17 @@ export function PlatformSettingsPage() { const [apiKey, setApiKey] = useState(''); const [saving, setSaving] = useState(false); + const [connectingCopilot, setConnectingCopilot] = useState(false); + const [disconnectingCopilot, setDisconnectingCopilot] = useState(false); const [saveError, setSaveError] = useState(null); const [saveSuccess, setSaveSuccess] = useState(false); + const copilotAuthorizationResult = searchParams.get('copilot_app_auth'); + + const dismissCopilotAuthorizationResult = () => { + const next = new URLSearchParams(searchParams); + next.delete('copilot_app_auth'); + setSearchParams(next, { replace: true }); + }; useEffect(() => { let cancelled = false; @@ -77,6 +140,30 @@ export function PlatformSettingsPage() { return () => { cancelled = true; }; }, []); + useEffect(() => { + let cancelled = false; + if (loading || mode !== 'copilot') return () => { cancelled = true; }; + + apiClient.getPlatformDefaultCopilotConnection() + .then((connection) => { + if (cancelled) return; + setPlatformCopilotConnection(connection); + setPlatformCopilotError(null); + }) + .catch((err) => { + if (cancelled) return; + setPlatformCopilotConnection(null); + setPlatformCopilotError(formatApiErrorMessage(err)); + }); + + return () => { cancelled = true; }; + }, [loading, mode]); + + useEffect(() => { + if (!setupRequired || !onRetryAccess) return; + if (existingConfig || platformCopilotConnection?.connected) onRetryAccess(); + }, [existingConfig, onRetryAccess, platformCopilotConnection?.connected, setupRequired]); + const handleModeChange = (_: unknown, data: RadioGroupOnChangeData) => { const next = data.value as AiMode; setMode(next); @@ -100,6 +187,7 @@ export function PlatformSettingsPage() { setMode(refreshed ? 'byok' : 'copilot'); setApiKey(''); setSaveSuccess(true); + onRetryAccess?.(); } catch (err) { setSaveError(formatApiErrorMessage(err)); } finally { @@ -117,6 +205,7 @@ export function PlatformSettingsPage() { setMode('copilot'); setApiKey(''); setSaveSuccess(true); + onRetryAccess?.(); } catch (err) { setSaveError(formatApiErrorMessage(err)); } finally { @@ -124,17 +213,88 @@ export function PlatformSettingsPage() { } }; + const refreshPlatformCopilotConnection = async () => { + try { + const connection = await apiClient.getPlatformDefaultCopilotConnection(); + setPlatformCopilotConnection(connection); + setPlatformCopilotError(null); + } catch (err) { + setPlatformCopilotConnection(null); + setPlatformCopilotError(formatApiErrorMessage(err)); + } + }; + + const handleConnectPlatformCopilot = async () => { + setConnectingCopilot(true); + setPlatformCopilotError(null); + try { + const handoff = await apiClient.beginPlatformDefaultCopilotAuthorization(); + window.location.assign(handoff.authorization_url); + } catch (err) { + setPlatformCopilotError(formatApiErrorMessage(err)); + setConnectingCopilot(false); + } + }; + + const handleDisconnectPlatformCopilot = async () => { + setDisconnectingCopilot(true); + setPlatformCopilotError(null); + try { + await apiClient.disconnectPlatformDefaultCopilotConnection(); + setPlatformCopilotConnection({ connected: false, github_login: null }); + await refreshPlatformCopilotConnection(); + setSaveSuccess(true); + onRetryAccess?.(); + } catch (err) { + setPlatformCopilotError(formatApiErrorMessage(err)); + } finally { + setDisconnectingCopilot(false); + } + }; + + const authorizationResult = isPlatformCopilotAuthorizationResultCode(copilotAuthorizationResult) + ? PLATFORM_COPILOT_AUTH_RESULTS[copilotAuthorizationResult] + : copilotAuthorizationResult + ? { + intent: 'error' as const, + message: 'The GitHub Copilot connection could not be completed. Start a new connection from Platform settings.', + } + : null; + return ( + {setupRequired && ( +
+ + + Agentweaver is locked until an administrator configures either a deployment-wide custom key + or a platform-default GitHub Copilot account. + + + {onRetryAccess && ( +
+ +
+ )} +
+ )} Choose exactly one AI source for the whole deployment. This is not per-project or per-person — it applies to everyone, including background and scheduled runs. + {authorizationResult && ( + + {authorizationResult.message} + + + )} {loading && } {loadError && ( {loadError} @@ -142,16 +302,75 @@ export function PlatformSettingsPage() { {!loading && !loadError && (
- + {mode === 'copilot' && ( <> - In this mode, every signed-in person connects their own GitHub Copilot login to - use AI features. + In this mode, a Platform Admin connects one deployment-wide GitHub Copilot account + for unattended and background work. Project-scoped Copilot connections remain + separate and can still be managed inside individual project settings. +
+ +
+ {platformCopilotError && ( + + {platformCopilotError} + + )} + {!platformCopilotError && platformCopilotConnection?.connected && ( + + + Connected GitHub login: @{platformCopilotConnection.github_login ?? 'unknown'} + + + )} + {!platformCopilotError && platformCopilotConnection && !platformCopilotConnection.connected && ( + + No platform-default GitHub Copilot account is connected yet. + + )} +
+ + + {platformCopilotConnection?.connected && ( + + )} + {(connectingCopilot || disconnectingCopilot) && ( +
+
+
+
{existingConfig && (