feat: Bao/Iris-Statusrechte + Bao→Iris-Notifications + Agent-Workflow-Übersicht
- Bao darf jetzt Status ändern (neben Iris), Sub-Agents weiterhin nicht - CanEditContent für Inhaltsbearbeitung durch alle bekannten Caller - Bao-Content-Änderungen triggern task_content_changed-Notification an Iris - Bao-Status-Änderungen triggern task_status_changed-Notification an Iris - Iris-Status-Änderungen triggern task_status_changed-Notification an Bao - Neue WorkTask-Felder: IsAgentTask (bool), ExpectedFrom (string) - Agent-Workflow-API: CreateAgentTask, WaitingTasks, AgentOverview - Frontend: Agent-Task-Badge, Iris-Overview-Panel, isBao-Getter - Login-Rate-Limiter mit strukturiertem JSON-Fehlermeldungs-Body - Volume-Name: nexus-postgres → postgres-data (Standardisierung)
This commit is contained in:
@@ -83,6 +83,43 @@ public static class TaskStateHelper
|
||||
string.Equals(state, "Done", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(state, "Backlog", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the caller is allowed to change this task's state.
|
||||
/// POLICY:
|
||||
/// - **Iris und Bao** dürfen Status ändern / verschieben.
|
||||
/// - Sub-agents (programmer, reviewer, architekt) dürfen NIEMALS Status ändern.
|
||||
/// - 'nexus-system' ist ein technischer Fallback für automatische Cron/Reset-Workflows.
|
||||
/// - Jeder andere (unbekannt, leer) wird abgewiesen.
|
||||
/// </summary>
|
||||
public static bool CanChangeState(string? callerAgent, WorkTask task)
|
||||
{
|
||||
var caller = callerAgent?.Trim().ToLowerInvariant() ?? "";
|
||||
|
||||
// Sub-agents must never move state
|
||||
var subAgents = new HashSet<string> { "programmer", "reviewer", "architekt" };
|
||||
if (subAgents.Contains(caller)) return false;
|
||||
|
||||
// Technischer Fallback: nur für interne System-Operationen (Cron, ResetStale)
|
||||
if (caller == "nexus-system") return true;
|
||||
|
||||
// Iris und Bao dürfen Status ändern
|
||||
return caller == "iris" || caller == "bao";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the caller is allowed to edit a task's content fields
|
||||
/// (title, detail, priority, assignedTo, dueDate).
|
||||
/// POLICY:
|
||||
/// - Alle (iris, bao, sub-agents, nexus-system) dürfen inhaltlich bearbeiten.
|
||||
/// - Nur unbekannte/leere Caller werden abgewiesen.
|
||||
/// </summary>
|
||||
public static bool CanEditContent(string? callerAgent)
|
||||
{
|
||||
var caller = callerAgent?.Trim().ToLowerInvariant() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(caller)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Group key for board responses (lowercased English state).</summary>
|
||||
public static string BoardGroupKey(string? state)
|
||||
{
|
||||
@@ -137,6 +174,19 @@ public sealed class WorkTask
|
||||
public string Priority { get; set; } = "Normal";
|
||||
public string Source { get; set; } = "bao";
|
||||
public string? AssignedTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True if this task was created programmatically by an agent (not manually by Bao).
|
||||
/// Agent-tasks in the board are subject to stricter workflow rules.
|
||||
/// </summary>
|
||||
public bool IsAgentTask { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Which agent/user is expected to respond next.
|
||||
/// Helps Iris see who she is waiting for.
|
||||
/// </summary>
|
||||
public string? ExpectedFrom { get; set; }
|
||||
|
||||
public Guid? ParentTaskId { get; set; }
|
||||
public WorkTask? ParentTask { get; set; }
|
||||
public ICollection<WorkTask> ChildTasks { get; set; } = new List<WorkTask>();
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Nexus.Api.Data;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Nexus.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(NexusDbContext))]
|
||||
[Migration("20260620174200_AddAgentTaskFields")]
|
||||
partial class AddAgentTaskFields
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<Guid?>("TaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.HasIndex("TaskId");
|
||||
|
||||
b.ToTable("Activity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ForUser")
|
||||
.IsRequired()
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<bool>("IsRead")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<Guid?>("TaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ForUser", "IsRead", "CreatedAt");
|
||||
|
||||
b.ToTable("Notifications");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(160)
|
||||
.HasColumnType("character varying(160)");
|
||||
|
||||
b.Property<int>("Progress")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("FamilyId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ReplacedByTokenHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "FamilyId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AssignedTo")
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Detail")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<DateTimeOffset?>("DueDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ExpectedFrom")
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<bool>("IsAgentTask")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid?>("ParentTaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Priority")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(240)
|
||||
.HasColumnType("character varying(240)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssignedTo");
|
||||
|
||||
b.HasIndex("ExpectedFrom");
|
||||
|
||||
b.HasIndex("IsAgentTask");
|
||||
|
||||
b.HasIndex("ParentTaskId");
|
||||
|
||||
b.HasIndex("Source");
|
||||
|
||||
b.ToTable("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("Nexus.Api.Data.NexusUser", "User")
|
||||
.WithMany("RefreshTokens")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||
{
|
||||
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
|
||||
.WithMany("ChildTasks")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("ParentTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||
{
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||
{
|
||||
b.Navigation("ChildTasks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Nexus.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAgentTaskFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsAgentTask",
|
||||
table: "Tasks",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExpectedFrom",
|
||||
table: "Tasks",
|
||||
type: "character varying(60)",
|
||||
maxLength: 60,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tasks_IsAgentTask",
|
||||
table: "Tasks",
|
||||
column: "IsAgentTask");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tasks_ExpectedFrom",
|
||||
table: "Tasks",
|
||||
column: "ExpectedFrom");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Tasks_IsAgentTask",
|
||||
table: "Tasks");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Tasks_ExpectedFrom",
|
||||
table: "Tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExpectedFrom",
|
||||
table: "Tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsAgentTask",
|
||||
table: "Tasks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,13 @@ namespace Nexus.Api.Migrations
|
||||
b.Property<DateTimeOffset?>("DueDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ExpectedFrom")
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<bool>("IsAgentTask")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid?>("ParentTaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
@@ -265,6 +272,10 @@ namespace Nexus.Api.Migrations
|
||||
|
||||
b.HasIndex("AssignedTo");
|
||||
|
||||
b.HasIndex("ExpectedFrom");
|
||||
|
||||
b.HasIndex("IsAgentTask");
|
||||
|
||||
b.HasIndex("ParentTaskId");
|
||||
|
||||
b.HasIndex("Source");
|
||||
|
||||
@@ -20,8 +20,11 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
|
||||
entity.Property(x => x.Detail).HasMaxLength(2000);
|
||||
entity.Property(x => x.Source).HasMaxLength(60);
|
||||
entity.Property(x => x.AssignedTo).HasMaxLength(60);
|
||||
entity.Property(x => x.ExpectedFrom).HasMaxLength(60);
|
||||
entity.HasIndex(x => x.Source);
|
||||
entity.HasIndex(x => x.AssignedTo);
|
||||
entity.HasIndex(x => x.IsAgentTask);
|
||||
entity.HasIndex(x => x.ExpectedFrom);
|
||||
entity.HasOne(x => x.ParentTask)
|
||||
.WithMany(x => x.ChildTasks)
|
||||
.HasForeignKey(x => x.ParentTaskId)
|
||||
|
||||
Reference in New Issue
Block a user