fad476f115
Skills move from a global Git-only registry to a per-company library that orgs author and
version in-app — Git stays as the shared *starter* library.
Domain & persistence:
- Skill gains OrganizationId (null = shared builtin, visible to every org), Origin
(Builtin | Authored | Installed), AuthoredByMemberId. Identity is now
(OrganizationId, SkillKey, Version); the unique index uses NULLS NOT DISTINCT so builtins
stay unique by key+version while each org gets its own namespace (and can fork a builtin).
AddSkillOwnership migration backfills existing rows as Builtin.
- Owned GoldenExample rows are cloned in Skill.Index so a fork can't re-parent the source's
tracked entities.
Authoring (tenant, dynamic):
- POST /api/skills/authored — structured fields → same indexer pipeline (embedding +
publish gate apply identically), tagged org + author. POST /api/skills/{key}/fork copies a
builtin/global skill into your org as an editable Authored draft. List/Get are org-scoped
(your org + shared builtins). New Capability.ManageSkills (Owner + TeamOwner), audited.
- GET /api/skills/marketplace: read-only seam listing public skills across orgs (install is
the next step).
Security (from adversarial review — two confirmed criticals):
- Managing shared builtins is an operator action, not a tenant one. /index (posts arbitrary
content as a global builtin) and /sync (re-indexes the shared library) now require a
platform admin key (X-Skills-Admin-Key, fixed-time compare, fail-closed when unset) via
SkillAdminOptions — previously any authenticated user of any org could inject/poison global
skills. New test asserts an authenticated Owner without the key gets 403 on both.
UI: new /skills library page — browse shared + org skills grouped by key with their versions,
create / new-version / fork, golden-test editor + body, Draft/Published badge and the
publish-gate hint (needs roles + ≥1 golden test).
Verified: ArchitectureTests 8/8, IntegrationTests 46/46 (new SkillLibraryTests: org
isolation, version coexistence, fork, publish gate, Member 403, admin-gate 403), client build
green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
150 lines
6.3 KiB
C#
150 lines
6.3 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using TeamUp.Modules.Assembler.Queue;
|
|
using TeamUp.Modules.Assembler.Runtime;
|
|
using Xunit;
|
|
|
|
namespace TeamUp.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// The core product thesis: ANY seat can be AI-staffed — a role is just a name + skill atoms.
|
|
/// An "Engineer" seat (not PO, not QA) runs the same pipeline: skills assemble, the model is
|
|
/// called, and the implement-code proposal is held for review like any other governed action.
|
|
/// </summary>
|
|
public sealed class AnyRoleSeatTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
|
{
|
|
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
|
|
|
|
private sealed record IdResponse(Guid Id);
|
|
|
|
private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
|
|
|
|
private sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId);
|
|
|
|
private sealed record SyncResult(int Indexed);
|
|
|
|
private sealed record RunResponse(
|
|
Guid Id, Guid SeatId, Guid WorkItemId, Guid? AgentId, string Status,
|
|
string? ActionType, string? ActionRisk, string? Prompt, string? Output, string? Error);
|
|
|
|
private sealed record ReviewItemResponse(
|
|
Guid Id, Guid OrganizationId, Guid TeamId, Guid AgentRunId, Guid AgentId, Guid WorkItemId,
|
|
string ActionKind, string Risk, string Title, string Content, List<string> ChildTitles,
|
|
string? Trace, string Status, string? Decision, double? EditDistance, DateTimeOffset CreatedAtUtc);
|
|
|
|
[Fact]
|
|
public async Task An_engineer_seat_runs_the_same_governed_pipeline()
|
|
{
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
["GitSource:Provider"] = "filesystem",
|
|
["GitSource:Root"] = LocateSkillsDirectory(),
|
|
};
|
|
|
|
await using var factory = new TeamUpWebFactory(postgres.ConnectionString, settings);
|
|
using var anon = factory.CreateClient();
|
|
|
|
var owner = await PostOk<BootstrapResponse>(anon, "/api/identity/bootstrap", new
|
|
{
|
|
organizationName = "AliaSaaS",
|
|
ownerEmail = "owner@alia.test",
|
|
ownerDisplayName = "Owner",
|
|
ownerPassword = "Passw0rd!",
|
|
});
|
|
using var client = Authed(factory, owner.Token);
|
|
|
|
await client.PostAsJsonAsync("/api/orgboard/organizations", new { organizationId = owner.OrganizationId, name = "AliaSaaS" });
|
|
var team = await PostOk<TeamResponse>(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "IPNOPS" });
|
|
var config = await PostOk<IdResponse>(client, "/api/integrations/api-configs", new
|
|
{
|
|
organizationId = owner.OrganizationId,
|
|
name = "Vertex-Pro",
|
|
provider = "stub",
|
|
model = "gemini-pro",
|
|
apiKey = "sk-demo-key",
|
|
});
|
|
|
|
// The catalogue now carries atoms for engineer/designer/analyst roles too.
|
|
var sync = await PostOk<SyncResult>(client, "/api/skills/sync", new { });
|
|
Assert.True(sync.Indexed >= 8);
|
|
|
|
// Staff an ENGINEER seat with AI — same configurator, different atoms.
|
|
var seat = await PostOk<SeatResponse>(client, "/api/orgboard/seats", new { teamId = team.Id, roleName = "Backend Engineer" });
|
|
await client.PostAsJsonAsync($"/api/orgboard/seats/{seat.Id}/agent", new
|
|
{
|
|
name = "Edison",
|
|
monogram = "ED",
|
|
autonomy = "Gated",
|
|
apiConfigId = config.Id,
|
|
skillKeys = new[] { "code-implementation", "bug-diagnosis" },
|
|
docs = Array.Empty<string>(),
|
|
});
|
|
|
|
var task = await PostOk<RunTask>(client, "/api/orgboard/tasks", new
|
|
{
|
|
teamId = team.Id,
|
|
title = "Implement the logout endpoint",
|
|
description = "POST /logout clears the session.",
|
|
type = "Story",
|
|
});
|
|
|
|
var run = await PostOk<RunResponse>(client, "/api/assembler/runs", new { seatId = seat.Id, workItemId = task.Id });
|
|
await DrainOneJob(factory);
|
|
|
|
var done = await client.GetFromJsonAsync<RunResponse>($"/api/assembler/runs/{run.Id}");
|
|
Assert.Equal("Completed", done!.Status);
|
|
Assert.Equal("implement-code", done.ActionType); // the engineer atom's primary action
|
|
Assert.Equal("Draft", done.ActionRisk);
|
|
Assert.Contains("Code Implementation", done.Prompt); // the skill body assembled in
|
|
|
|
// Gated engineer output is governed exactly like PO/QA output: held for human review.
|
|
var pending = await client.GetFromJsonAsync<List<ReviewItemResponse>>(
|
|
$"/api/governance/reviews?organizationId={owner.OrganizationId}");
|
|
var held = Assert.Single(pending!);
|
|
Assert.Equal("implement-code", held.ActionKind);
|
|
Assert.Equal(task.Id, held.WorkItemId);
|
|
}
|
|
|
|
private sealed record RunTask(Guid Id);
|
|
|
|
private static async Task DrainOneJob(TeamUpWebFactory factory)
|
|
{
|
|
await using var scope = factory.Services.CreateAsyncScope();
|
|
var queue = scope.ServiceProvider.GetRequiredService<JobQueue>();
|
|
var job = await queue.ClaimNextAsync("test-worker");
|
|
Assert.NotNull(job);
|
|
await scope.ServiceProvider.GetRequiredService<AgentRunExecutor>().ProcessAsync(job!);
|
|
}
|
|
|
|
private static HttpClient Authed(TeamUpWebFactory factory, string token)
|
|
{
|
|
var client = factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
|
|
return client;
|
|
}
|
|
|
|
private static async Task<T> PostOk<T>(HttpClient client, string url, object body)
|
|
{
|
|
var response = await client.PostAsJsonAsync(url, body);
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var value = await response.Content.ReadFromJsonAsync<T>();
|
|
Assert.NotNull(value);
|
|
return value!;
|
|
}
|
|
|
|
private static string LocateSkillsDirectory()
|
|
{
|
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "TeamUp.slnx")))
|
|
{
|
|
dir = dir.Parent;
|
|
}
|
|
|
|
Assert.NotNull(dir);
|
|
return Path.Combine(dir!.FullName, "skills");
|
|
}
|
|
}
|