Merge: dynamic per-org skill library (authoring + versioning + fork) + builtin-management hardening
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { OrgChartPage } from '@/pages/OrgChartPage'
|
||||
import { PerformancePage } from '@/pages/PerformancePage'
|
||||
import { ReviewsPage } from '@/pages/ReviewsPage'
|
||||
import { SeatsPage } from '@/pages/SeatsPage'
|
||||
import { SkillsPage } from '@/pages/SkillsPage'
|
||||
import { StructurePage } from '@/pages/StructurePage'
|
||||
import { useAuth } from '@/store/auth'
|
||||
|
||||
@@ -27,6 +28,7 @@ export default function App() {
|
||||
<Route path="/members" element={token ? <MembersPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/org" element={token ? <OrgChartPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/structure" element={token ? <StructurePage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/skills" element={token ? <SkillsPage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="/performance" element={token ? <PerformancePage /> : <Navigate to="/login" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link, useLocation } from 'react-router'
|
||||
import {
|
||||
BookMarked,
|
||||
Bot,
|
||||
Boxes,
|
||||
ChartColumn,
|
||||
@@ -42,6 +43,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<NavItem icon={Inbox} label="Cartable" to="/cartable" />
|
||||
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
|
||||
<NavItem icon={Bot} label="AI seats" to="/seats" />
|
||||
<NavItem icon={BookMarked} label="Skills" to="/skills" />
|
||||
<NavItem icon={Network} label="Org chart" to="/org" />
|
||||
<NavItem icon={Boxes} label="Structure" to="/structure" />
|
||||
<NavItem icon={Users} label="Members" to="/members" />
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { BookMarked, GitFork, Pencil, Plus, Store, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AppShell } from '@/components/AppShell'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuth } from '@/store/auth'
|
||||
|
||||
interface ActionDto {
|
||||
name: string
|
||||
risk: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
interface GoldenTest {
|
||||
input: string
|
||||
expected: string
|
||||
}
|
||||
|
||||
interface SkillSummary {
|
||||
skillKey: string
|
||||
name: string
|
||||
version: string
|
||||
summary: string | null
|
||||
roles: string[]
|
||||
visibility: string
|
||||
minTier: string
|
||||
status: string
|
||||
origin: string
|
||||
organizationId: string | null
|
||||
goldenTestCount: number
|
||||
actions: ActionDto[]
|
||||
}
|
||||
|
||||
interface SkillDetail {
|
||||
skill: SkillSummary
|
||||
inputs: string | null
|
||||
outputs: string | null
|
||||
tools: string[]
|
||||
context: string[]
|
||||
goldenTests: GoldenTest[]
|
||||
body: string
|
||||
}
|
||||
|
||||
type Mode = 'new' | 'version' | 'edit'
|
||||
|
||||
interface FormState {
|
||||
mode: Mode
|
||||
skillKey: string
|
||||
name: string
|
||||
version: string
|
||||
summary: string
|
||||
roles: string
|
||||
inputs: string
|
||||
outputs: string
|
||||
tools: string
|
||||
context: string
|
||||
visibility: string
|
||||
minTier: string
|
||||
body: string
|
||||
actions: ActionDto[]
|
||||
goldenTests: GoldenTest[]
|
||||
}
|
||||
|
||||
const COMMON_ROLES = 'product-owner, engineer, qa, designer, analyst'
|
||||
const RISKS = ['read', 'draft', 'publish', 'destructive']
|
||||
const VISIBILITIES = ['public', 'private']
|
||||
const TIERS = ['free', 'team', 'scale', 'enterprise']
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
mode: 'new',
|
||||
skillKey: '',
|
||||
name: '',
|
||||
version: '1.0.0',
|
||||
summary: '',
|
||||
roles: '',
|
||||
inputs: '',
|
||||
outputs: '',
|
||||
tools: '',
|
||||
context: '',
|
||||
visibility: 'public',
|
||||
minTier: 'free',
|
||||
body: '',
|
||||
actions: [],
|
||||
goldenTests: [],
|
||||
})
|
||||
|
||||
/** Bump the last numeric segment of a semver-ish string (1.0.0 → 1.0.1). */
|
||||
function bumpPatch(version: string): string {
|
||||
const parts = version.split('.')
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const n = Number(parts[i])
|
||||
if (Number.isInteger(n)) {
|
||||
parts[i] = String(n + 1)
|
||||
return parts.join('.')
|
||||
}
|
||||
}
|
||||
return `${version}.1`
|
||||
}
|
||||
|
||||
const csv = (s: string): string[] =>
|
||||
s.split(',').map((x) => x.trim()).filter(Boolean)
|
||||
|
||||
/** The org's skill library: builtin starter skills + skills the company authors and versions itself. */
|
||||
export function SkillsPage() {
|
||||
const organizationId = useAuth((s) => s.organizationId)
|
||||
const [tab, setTab] = useState<'library' | 'marketplace'>('library')
|
||||
const [skills, setSkills] = useState<SkillSummary[]>([])
|
||||
const [marketplace, setMarketplace] = useState<SkillSummary[]>([])
|
||||
const [form, setForm] = useState<FormState | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!organizationId) return
|
||||
try {
|
||||
const [lib, market] = await Promise.all([
|
||||
api.get<SkillSummary[]>(`/api/skills?organizationId=${organizationId}`),
|
||||
api.get<SkillSummary[]>(`/api/skills/marketplace`),
|
||||
])
|
||||
setSkills(lib)
|
||||
setMarketplace(market)
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}, [organizationId])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// Group every version under its key; newest (and the org's own) first comes from the API order.
|
||||
const groups = useMemo(() => {
|
||||
const byKey = new Map<string, SkillSummary[]>()
|
||||
for (const s of skills) {
|
||||
const list = byKey.get(s.skillKey) ?? []
|
||||
list.push(s)
|
||||
byKey.set(s.skillKey, list)
|
||||
}
|
||||
return [...byKey.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
||||
}, [skills])
|
||||
|
||||
const openForm = async (key: string, version: string, mode: Mode) => {
|
||||
try {
|
||||
const details = await api.get<SkillDetail[]>(`/api/skills/${key}?organizationId=${organizationId}`)
|
||||
const d = details.find((x) => x.skill.version === version) ?? details[0]
|
||||
if (!d) return
|
||||
setForm({
|
||||
mode,
|
||||
skillKey: d.skill.skillKey,
|
||||
name: d.skill.name,
|
||||
version: mode === 'version' ? bumpPatch(d.skill.version) : d.skill.version,
|
||||
summary: d.skill.summary ?? '',
|
||||
roles: d.skill.roles.join(', '),
|
||||
inputs: d.inputs ?? '',
|
||||
outputs: d.outputs ?? '',
|
||||
tools: d.tools.join(', '),
|
||||
context: d.context.join(', '),
|
||||
visibility: d.skill.visibility === 'PrivateToOrg' ? 'private' : 'public',
|
||||
minTier: d.skill.minTier.toLowerCase(),
|
||||
body: d.body,
|
||||
actions: d.skill.actions.map((a) => ({ name: a.name, risk: a.risk.toLowerCase(), description: a.description ?? '' })),
|
||||
goldenTests: d.goldenTests.map((g) => ({ ...g })),
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const fork = async (key: string, version: string) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.post(`/api/skills/${key}/fork`, { organizationId, version })
|
||||
toast.success(`Forked ${key} into your org — edit it to customize.`)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!form) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.post('/api/skills/authored', {
|
||||
organizationId,
|
||||
skillKey: form.skillKey.trim(),
|
||||
name: form.name.trim(),
|
||||
version: form.version.trim(),
|
||||
summary: form.summary.trim() || null,
|
||||
roles: csv(form.roles),
|
||||
inputs: form.inputs.trim() || null,
|
||||
outputs: form.outputs.trim() || null,
|
||||
tools: csv(form.tools),
|
||||
context: csv(form.context),
|
||||
visibility: form.visibility,
|
||||
minTier: form.minTier,
|
||||
body: form.body,
|
||||
actions: form.actions
|
||||
.filter((a) => a.name.trim())
|
||||
.map((a) => ({ name: a.name.trim(), risk: a.risk, description: a.description?.trim() || null })),
|
||||
goldenTests: form.goldenTests.filter((g) => g.input.trim() && g.expected.trim()),
|
||||
})
|
||||
toast.success(`Saved ${form.skillKey}@${form.version}.`)
|
||||
setForm(null)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const willPublish = !!form && csv(form.roles).length > 0 && form.goldenTests.some((g) => g.input.trim() && g.expected.trim())
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="mx-auto max-w-5xl p-6">
|
||||
<header className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<BookMarked className="size-6" /> Skills
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your company's skill library. Builtin starter skills are shared; author and version your own.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setForm(emptyForm())}>
|
||||
<Plus data-icon="inline-start" /> New skill
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="mb-4 inline-flex rounded-lg border p-1">
|
||||
<SegBtn active={tab === 'library'} onClick={() => setTab('library')} icon={BookMarked}>Library</SegBtn>
|
||||
<SegBtn active={tab === 'marketplace'} onClick={() => setTab('marketplace')} icon={Store}>Marketplace</SegBtn>
|
||||
</div>
|
||||
|
||||
{tab === 'library' ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
{groups.map(([key, versions]) => (
|
||||
<SkillGroupCard
|
||||
key={key}
|
||||
versions={versions}
|
||||
busy={busy}
|
||||
onNewVersion={(v) => openForm(key, v, 'version')}
|
||||
onEdit={(v) => openForm(key, v, 'edit')}
|
||||
onFork={(v) => fork(key, v)}
|
||||
/>
|
||||
))}
|
||||
{groups.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No skills yet. Run a Git sync for builtins, or author one.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Public skills shared by other organizations. One-click install lands in the next step.
|
||||
</p>
|
||||
{marketplace.map((s) => (
|
||||
<Card key={`${s.organizationId}-${s.skillKey}-${s.version}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{s.name} <Badge variant="outline">{s.version}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>{s.summary}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center gap-2">
|
||||
{s.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
|
||||
<Button size="sm" variant="outline" disabled className="ml-auto">Install (soon)</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{marketplace.length === 0 && <p className="text-sm text-muted-foreground">Nothing published yet.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{form && (
|
||||
<Sheet open onOpenChange={(o) => !o && setForm(null)}>
|
||||
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{form.mode === 'new' ? 'New skill' : form.mode === 'version' ? `New version of ${form.skillKey}` : `Edit ${form.skillKey}`}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{willPublish
|
||||
? 'Has roles + a golden test — saves as Published.'
|
||||
: 'Add ≥1 role and ≥1 golden test to publish; otherwise saved as Draft.'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 pb-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Skill key (id)">
|
||||
<Input
|
||||
value={form.skillKey}
|
||||
disabled={form.mode !== 'new'}
|
||||
onChange={(e) => setForm({ ...form, skillKey: e.target.value })}
|
||||
placeholder="api-endpoint-design"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Version">
|
||||
<Input value={form.version} disabled={form.mode === 'edit'} onChange={(e) => setForm({ ...form, version: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Name">
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="API Endpoint Design" />
|
||||
</Field>
|
||||
<Field label="Summary">
|
||||
<Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={`Roles (comma-separated — e.g. ${COMMON_ROLES})`}>
|
||||
<Input value={form.roles} onChange={(e) => setForm({ ...form, roles: e.target.value })} placeholder="engineer" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Inputs">
|
||||
<Input value={form.inputs} onChange={(e) => setForm({ ...form, inputs: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Outputs">
|
||||
<Input value={form.outputs} onChange={(e) => setForm({ ...form, outputs: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Visibility">
|
||||
<Pick value={form.visibility} options={VISIBILITIES} onChange={(v) => setForm({ ...form, visibility: v })} />
|
||||
</Field>
|
||||
<Field label="Min tier">
|
||||
<Pick value={form.minTier} options={TIERS} onChange={(v) => setForm({ ...form, minTier: v })} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Tools (comma-separated)">
|
||||
<Input value={form.tools} onChange={(e) => setForm({ ...form, tools: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Context docs (comma-separated)">
|
||||
<Input value={form.context} onChange={(e) => setForm({ ...form, context: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Repeater
|
||||
label="Actions (risk-tagged)"
|
||||
onAdd={() => setForm({ ...form, actions: [...form.actions, { name: '', risk: 'draft', description: '' }] })}
|
||||
>
|
||||
{form.actions.map((a, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
className="flex-1" placeholder="action name" value={a.name}
|
||||
onChange={(e) => setForm({ ...form, actions: form.actions.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)) })}
|
||||
/>
|
||||
<Pick
|
||||
value={a.risk} options={RISKS} className="w-32"
|
||||
onChange={(v) => setForm({ ...form, actions: form.actions.map((x, j) => (j === i ? { ...x, risk: v } : x)) })}
|
||||
/>
|
||||
<Button size="icon" variant="ghost" onClick={() => setForm({ ...form, actions: form.actions.filter((_, j) => j !== i) })}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</Repeater>
|
||||
|
||||
<Repeater
|
||||
label="Golden tests (gate publishing)"
|
||||
onAdd={() => setForm({ ...form, goldenTests: [...form.goldenTests, { input: '', expected: '' }] })}
|
||||
>
|
||||
{form.goldenTests.map((g, i) => (
|
||||
<div key={i} className="flex flex-col gap-2 rounded-md border p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="flex-1" placeholder="input" value={g.input}
|
||||
onChange={(e) => setForm({ ...form, goldenTests: form.goldenTests.map((x, j) => (j === i ? { ...x, input: e.target.value } : x)) })}
|
||||
/>
|
||||
<Button size="icon" variant="ghost" onClick={() => setForm({ ...form, goldenTests: form.goldenTests.filter((_, j) => j !== i) })}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
rows={2} placeholder="expected output" value={g.expected}
|
||||
onChange={(e) => setForm({ ...form, goldenTests: form.goldenTests.map((x, j) => (j === i ? { ...x, expected: e.target.value } : x)) })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Repeater>
|
||||
|
||||
<Field label="Body (the prompt the agent runs)">
|
||||
<Textarea rows={8} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} placeholder="You are the engineer. Turn the input into…" />
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setForm(null)}>Cancel</Button>
|
||||
<Button disabled={busy || !form.skillKey.trim() || !form.name.trim() || !form.body.trim()} onClick={save}>
|
||||
Save {willPublish ? '& publish' : 'draft'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function SkillGroupCard({
|
||||
versions,
|
||||
busy,
|
||||
onNewVersion,
|
||||
onEdit,
|
||||
onFork,
|
||||
}: {
|
||||
versions: SkillSummary[]
|
||||
busy: boolean
|
||||
onNewVersion: (version: string) => void
|
||||
onEdit: (version: string) => void
|
||||
onFork: (version: string) => void
|
||||
}) {
|
||||
const [selected, setSelected] = useState(versions[0].version)
|
||||
const current = versions.find((v) => v.version === selected) ?? versions[0]
|
||||
const isBuiltin = current.origin === 'Builtin'
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{current.name}
|
||||
<span className="font-mono text-xs text-muted-foreground">{current.skillKey}</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">{current.summary}</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={current.status === 'Published' ? 'default' : 'secondary'}>{current.status}</Badge>
|
||||
<Badge variant="outline">{current.origin}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-center gap-2">
|
||||
{versions.length > 1 ? (
|
||||
<Pick value={selected} options={versions.map((v) => v.version)} className="w-28" onChange={setSelected} />
|
||||
) : (
|
||||
<Badge variant="outline">{current.version}</Badge>
|
||||
)}
|
||||
{current.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
|
||||
<span className="text-xs text-muted-foreground">{current.goldenTestCount} golden test{current.goldenTestCount === 1 ? '' : 's'}</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{isBuiltin ? (
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => onFork(current.version)}>
|
||||
<GitFork data-icon="inline-start" /> Fork to my org
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => onEdit(current.version)}>
|
||||
<Pencil data-icon="inline-start" /> Edit
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" disabled={busy} onClick={() => onNewVersion(current.version)}>
|
||||
<Plus data-icon="inline-start" /> New version
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function SegBtn({ active, onClick, icon: Icon, children }: { active: boolean; onClick: () => void; icon: typeof BookMarked; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition ${active ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
<Icon className="size-4" /> {children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Pick({ value, options, onChange, className }: { value: string; options: string[]; onChange: (v: string) => void; className?: string }) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className={className ?? 'w-full'}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{options.map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
function Repeater({ label, onAdd, children }: { label: string; onAdd: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">{label}</Label>
|
||||
<Button size="sm" variant="ghost" onClick={onAdd}><Plus data-icon="inline-start" /> Add</Button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,10 @@ namespace TeamUp.Modules.Skills.Domain;
|
||||
/// </summary>
|
||||
internal sealed class Skill : Entity
|
||||
{
|
||||
/// <summary>Owning org. Null = a shared builtin (Git starter library), visible to every org.</summary>
|
||||
public Guid? OrganizationId { get; private set; }
|
||||
public SkillOrigin Origin { get; private set; }
|
||||
public Guid? AuthoredByMemberId { get; private set; }
|
||||
public string SkillKey { get; private set; } = null!;
|
||||
public string Name { get; private set; } = null!;
|
||||
public string Version { get; private set; } = null!;
|
||||
@@ -36,14 +40,16 @@ internal sealed class Skill : Entity
|
||||
{
|
||||
}
|
||||
|
||||
public static Skill Create(string skillKey, string version, DateTimeOffset nowUtc) =>
|
||||
new() { SkillKey = skillKey, Version = version, IndexedAtUtc = nowUtc };
|
||||
public static Skill Create(string skillKey, string version, Guid? organizationId, DateTimeOffset nowUtc) =>
|
||||
new() { SkillKey = skillKey, Version = version, OrganizationId = organizationId, IndexedAtUtc = nowUtc };
|
||||
|
||||
/// <summary>(Re)projects a parsed manifest + body onto this row. Used for both insert and update.</summary>
|
||||
public void Index(
|
||||
SkillManifest manifest,
|
||||
string body,
|
||||
string contentHash,
|
||||
SkillOrigin origin,
|
||||
Guid? authoredByMemberId,
|
||||
string? sourceRepo,
|
||||
string? sourcePath,
|
||||
string? sourceCommit,
|
||||
@@ -51,6 +57,8 @@ internal sealed class Skill : Entity
|
||||
SkillStatus status,
|
||||
DateTimeOffset nowUtc)
|
||||
{
|
||||
Origin = origin;
|
||||
AuthoredByMemberId = authoredByMemberId;
|
||||
Name = string.IsNullOrWhiteSpace(manifest.Name) ? manifest.Id : manifest.Name;
|
||||
Version = manifest.Version;
|
||||
Summary = manifest.Summary;
|
||||
@@ -62,7 +70,11 @@ internal sealed class Skill : Entity
|
||||
.ToList();
|
||||
Tools = manifest.Tools;
|
||||
Context = manifest.Context;
|
||||
GoldenTests = manifest.GoldenTests;
|
||||
// Fresh owned-entity instances — a manifest built from another skill (fork) must not
|
||||
// re-parent that skill's tracked GoldenExample rows onto this one.
|
||||
GoldenTests = manifest.GoldenTests
|
||||
.Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected })
|
||||
.ToList();
|
||||
Visibility = ParseVisibility(manifest.Visibility);
|
||||
MinTier = ParseTier(manifest.MinTier);
|
||||
Status = status;
|
||||
|
||||
@@ -31,6 +31,18 @@ internal enum SkillStatus
|
||||
Published,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where a skill row came from. <c>Builtin</c> = synced from the shared Git starter library
|
||||
/// (OrganizationId null, visible to every org). <c>Authored</c> = created in-app by an org.
|
||||
/// <c>Installed</c> = copied from the marketplace into an org (next step).
|
||||
/// </summary>
|
||||
internal enum SkillOrigin
|
||||
{
|
||||
Builtin,
|
||||
Authored,
|
||||
Installed,
|
||||
}
|
||||
|
||||
/// <summary>A risk-tagged action a skill can take. Stored as JSON on the skill.</summary>
|
||||
internal sealed class SkillAction
|
||||
{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
namespace TeamUp.Modules.Skills.Endpoints;
|
||||
|
||||
internal sealed record ActionDto(string Name, string Risk);
|
||||
internal sealed record ActionDto(string Name, string Risk, string? Description = null);
|
||||
|
||||
internal sealed record GoldenTestDto(string Input, string Expected);
|
||||
|
||||
internal sealed record SkillSummary(
|
||||
string SkillKey,
|
||||
@@ -11,6 +13,9 @@ internal sealed record SkillSummary(
|
||||
string Visibility,
|
||||
string MinTier,
|
||||
string Status,
|
||||
string Origin,
|
||||
Guid? OrganizationId,
|
||||
int GoldenTestCount,
|
||||
List<ActionDto> Actions);
|
||||
|
||||
internal sealed record SkillDetail(
|
||||
@@ -19,9 +24,33 @@ internal sealed record SkillDetail(
|
||||
string? Outputs,
|
||||
List<string> Tools,
|
||||
List<string> Context,
|
||||
int GoldenTestCount,
|
||||
List<GoldenTestDto> GoldenTests,
|
||||
string Body);
|
||||
|
||||
internal sealed record IndexRequest(string Content, string? SourceRepo, string? SourcePath, string? SourceCommit);
|
||||
|
||||
internal sealed record SyncResult(int Indexed);
|
||||
|
||||
/// <summary>
|
||||
/// Author or version an org-owned skill from structured fields. Re-saving the same
|
||||
/// (OrganizationId, SkillKey, Version) edits in place; bumping Version creates a new version.
|
||||
/// </summary>
|
||||
internal sealed record AuthorSkillRequest(
|
||||
Guid OrganizationId,
|
||||
string SkillKey,
|
||||
string Name,
|
||||
string Version,
|
||||
string? Summary,
|
||||
List<string> Roles,
|
||||
string? Inputs,
|
||||
string? Outputs,
|
||||
List<ActionDto> Actions,
|
||||
List<string> Tools,
|
||||
List<string> Context,
|
||||
string Visibility,
|
||||
string MinTier,
|
||||
string Body,
|
||||
List<GoldenTestDto> GoldenTests);
|
||||
|
||||
/// <summary>Copy a builtin/other skill into an org as an editable Authored skill.</summary>
|
||||
internal sealed record ForkSkillRequest(Guid OrganizationId, string Version, string? Name);
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeamUp.Modules.Skills.Domain;
|
||||
using TeamUp.Modules.Skills.Indexing;
|
||||
using TeamUp.Modules.Skills.Persistence;
|
||||
using TeamUp.Modules.Skills.Sync;
|
||||
using TeamUp.SharedKernel.Access;
|
||||
using TeamUp.SharedKernel.Auditing;
|
||||
using TeamUp.SharedKernel.Modularity;
|
||||
|
||||
namespace TeamUp.Modules.Skills.Endpoints;
|
||||
@@ -18,24 +23,64 @@ internal static class SkillsEndpoints
|
||||
|
||||
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("skills")));
|
||||
group.MapGet("/", ListSkills).RequireAuthorization();
|
||||
group.MapGet("/marketplace", Marketplace).RequireAuthorization();
|
||||
group.MapGet("/{key}", GetSkill).RequireAuthorization();
|
||||
group.MapPost("/authored", AuthorSkill).RequireAuthorization();
|
||||
group.MapPost("/{key}/fork", ForkSkill).RequireAuthorization();
|
||||
group.MapPost("/index", IndexSkill).RequireAuthorization();
|
||||
group.MapPost("/sync", Sync).RequireAuthorization();
|
||||
group.MapPost("/webhook/gitea", Webhook).AllowAnonymous();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Sync(SkillSyncService sync, CancellationToken ct) =>
|
||||
Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
|
||||
// Re-syncing the shared builtin library is an operator action, not a tenant one.
|
||||
private static async Task<IResult> Sync(
|
||||
HttpContext http, IOptions<SkillAdminOptions> admin, SkillSyncService sync, CancellationToken ct)
|
||||
{
|
||||
if (!IsPlatformAdmin(http, admin))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
// Gitea push webhook → re-sync the source. M2 re-indexes the whole source (idempotent);
|
||||
// signature verification + changed-file-only sync via the job queue land later.
|
||||
return Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
|
||||
}
|
||||
|
||||
// Gitea push webhook → re-sync the source. Re-reads only the trusted Git source (no caller
|
||||
// content). Signature verification + changed-file-only sync via the job queue land later.
|
||||
private static async Task<IResult> Webhook(SkillSyncService sync, CancellationToken ct) =>
|
||||
Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
|
||||
|
||||
private static async Task<IResult> ListSkills(
|
||||
string? role, string? visibility, SkillsDbContext db, CancellationToken ct)
|
||||
/// <summary>
|
||||
/// Builtins (null-org, all-tenant-visible) may only be managed by a platform operator holding
|
||||
/// the configured admin key. Fails closed when no key is configured.
|
||||
/// </summary>
|
||||
private static bool IsPlatformAdmin(HttpContext http, IOptions<SkillAdminOptions> admin)
|
||||
{
|
||||
var query = db.Skills.AsQueryable();
|
||||
var configured = admin.Value.AdminKey;
|
||||
if (string.IsNullOrEmpty(configured) ||
|
||||
!http.Request.Headers.TryGetValue("X-Skills-Admin-Key", out var provided))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(provided.ToString()), Encoding.UTF8.GetBytes(configured));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListSkills(
|
||||
Guid? organizationId, string? role, string? visibility,
|
||||
IPermissionService permissions, SkillsDbContext db, CancellationToken ct)
|
||||
{
|
||||
// The library a company sees = the shared builtin starter skills (null org) + its own.
|
||||
IQueryable<Skill> query = db.Skills.Where(s => s.OrganizationId == null);
|
||||
if (organizationId is { } orgId)
|
||||
{
|
||||
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(orgId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
query = db.Skills.Where(s => s.OrganizationId == null || s.OrganizationId == orgId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(role))
|
||||
{
|
||||
@@ -55,11 +100,30 @@ internal static class SkillsEndpoints
|
||||
return Results.Ok(skills.Select(ToSummary).ToList());
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetSkill(string key, SkillsDbContext db, CancellationToken ct)
|
||||
// Marketplace seam (read-only groundwork): publicly-shared, org-authored skills from any org.
|
||||
// Publishing controls and install-into-your-org land in the next step.
|
||||
private static async Task<IResult> Marketplace(SkillsDbContext db, CancellationToken ct)
|
||||
{
|
||||
var listed = await db.Skills
|
||||
.Where(s => s.Origin == SkillOrigin.Authored && s.Visibility == SkillVisibility.Public)
|
||||
.OrderBy(s => s.SkillKey)
|
||||
.ThenByDescending(s => s.Version)
|
||||
.ToListAsync(ct);
|
||||
return Results.Ok(listed.Select(ToSummary).ToList());
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetSkill(
|
||||
string key, Guid? organizationId, IPermissionService permissions, SkillsDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (organizationId is { } orgId && !permissions.Has(Capability.ViewBoard, ScopeRef.Org(orgId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var versions = await db.Skills
|
||||
.Where(s => s.SkillKey == key)
|
||||
.OrderByDescending(s => s.Version)
|
||||
.Where(s => s.SkillKey == key && (s.OrganizationId == null || s.OrganizationId == organizationId))
|
||||
.OrderByDescending(s => s.OrganizationId != null) // org's own first, then builtins
|
||||
.ThenByDescending(s => s.Version)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return versions.Count == 0
|
||||
@@ -67,8 +131,75 @@ internal static class SkillsEndpoints
|
||||
: Results.Ok(versions.Select(ToDetail).ToList());
|
||||
}
|
||||
|
||||
private static async Task<IResult> IndexSkill(IndexRequest request, SkillIndexer indexer, CancellationToken ct)
|
||||
private static async Task<IResult> AuthorSkill(
|
||||
AuthorSkillRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
IAuditLog audit, SkillIndexer indexer, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.ManageSkills, ScopeRef.Org(request.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SkillKey) ||
|
||||
string.IsNullOrWhiteSpace(request.Name) ||
|
||||
string.IsNullOrWhiteSpace(request.Version) ||
|
||||
string.IsNullOrWhiteSpace(request.Body))
|
||||
{
|
||||
return Results.BadRequest("skillKey, name, version, and body are required.");
|
||||
}
|
||||
|
||||
var manifest = ToManifest(request);
|
||||
var skill = await indexer.IndexAsync(
|
||||
manifest, request.Body.Trim(), SkillOwnership.Authored(request.OrganizationId, user.MemberId), ct);
|
||||
|
||||
await audit.WriteAsync(
|
||||
new AuditEvent("skill.authored", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
|
||||
return Results.Ok(ToDetail(skill));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ForkSkill(
|
||||
string key, ForkSkillRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
IAuditLog audit, SkillsDbContext db, SkillIndexer indexer, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.ManageSkills, ScopeRef.Org(request.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
// Fork a builtin (or the org's own) version into an editable, org-owned Authored copy.
|
||||
var source = await db.Skills.FirstOrDefaultAsync(
|
||||
s => s.SkillKey == key && s.Version == request.Version
|
||||
&& (s.OrganizationId == null || s.OrganizationId == request.OrganizationId),
|
||||
ct);
|
||||
if (source is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var manifest = ToManifest(source);
|
||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
manifest.Name = request.Name.Trim();
|
||||
}
|
||||
|
||||
var skill = await indexer.IndexAsync(
|
||||
manifest, source.Body, SkillOwnership.Authored(request.OrganizationId, user.MemberId), ct);
|
||||
|
||||
await audit.WriteAsync(
|
||||
new AuditEvent("skill.forked", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
|
||||
return Results.Ok(ToDetail(skill));
|
||||
}
|
||||
|
||||
// Posts raw content as a shared builtin → operator-only (otherwise any tenant could inject a
|
||||
// global skill). Tenants author org-owned skills via /authored instead.
|
||||
private static async Task<IResult> IndexSkill(
|
||||
HttpContext http, IOptions<SkillAdminOptions> admin, IndexRequest request, SkillIndexer indexer, CancellationToken ct)
|
||||
{
|
||||
if (!IsPlatformAdmin(http, admin))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Content))
|
||||
{
|
||||
return Results.BadRequest("content is required.");
|
||||
@@ -86,6 +217,46 @@ internal static class SkillsEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
private static SkillManifest ToManifest(AuthorSkillRequest request) => new()
|
||||
{
|
||||
Id = request.SkillKey.Trim(),
|
||||
Name = request.Name.Trim(),
|
||||
Version = request.Version.Trim(),
|
||||
Summary = request.Summary,
|
||||
Roles = request.Roles ?? [],
|
||||
Inputs = request.Inputs,
|
||||
Outputs = request.Outputs,
|
||||
Actions = (request.Actions ?? [])
|
||||
.Select(a => new ManifestAction { Name = a.Name, Risk = a.Risk, Description = a.Description })
|
||||
.ToList(),
|
||||
Tools = request.Tools ?? [],
|
||||
Context = request.Context ?? [],
|
||||
Visibility = string.IsNullOrWhiteSpace(request.Visibility) ? "public" : request.Visibility,
|
||||
MinTier = string.IsNullOrWhiteSpace(request.MinTier) ? "free" : request.MinTier,
|
||||
GoldenTests = (request.GoldenTests ?? [])
|
||||
.Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected })
|
||||
.ToList(),
|
||||
};
|
||||
|
||||
private static SkillManifest ToManifest(Skill skill) => new()
|
||||
{
|
||||
Id = skill.SkillKey,
|
||||
Name = skill.Name,
|
||||
Version = skill.Version,
|
||||
Summary = skill.Summary,
|
||||
Roles = [.. skill.Roles],
|
||||
Inputs = skill.Inputs,
|
||||
Outputs = skill.Outputs,
|
||||
Actions = skill.Actions
|
||||
.Select(a => new ManifestAction { Name = a.Name, Risk = a.Risk.ToString(), Description = a.Description })
|
||||
.ToList(),
|
||||
Tools = [.. skill.Tools],
|
||||
Context = [.. skill.Context],
|
||||
Visibility = skill.Visibility.ToString(),
|
||||
MinTier = skill.MinTier.ToString(),
|
||||
GoldenTests = [.. skill.GoldenTests], // Skill.Index clones these onto the new row.
|
||||
};
|
||||
|
||||
private static SkillSummary ToSummary(Skill skill) => new(
|
||||
skill.SkillKey,
|
||||
skill.Name,
|
||||
@@ -95,7 +266,10 @@ internal static class SkillsEndpoints
|
||||
skill.Visibility.ToString(),
|
||||
skill.MinTier.ToString(),
|
||||
skill.Status.ToString(),
|
||||
skill.Actions.Select(a => new ActionDto(a.Name, a.Risk.ToString())).ToList());
|
||||
skill.Origin.ToString(),
|
||||
skill.OrganizationId,
|
||||
skill.GoldenTests.Count,
|
||||
skill.Actions.Select(a => new ActionDto(a.Name, a.Risk.ToString(), a.Description)).ToList());
|
||||
|
||||
private static SkillDetail ToDetail(Skill skill) => new(
|
||||
ToSummary(skill),
|
||||
@@ -103,6 +277,6 @@ internal static class SkillsEndpoints
|
||||
skill.Outputs,
|
||||
skill.Tools,
|
||||
skill.Context,
|
||||
skill.GoldenTests.Count,
|
||||
skill.GoldenTests.Select(g => new GoldenTestDto(g.Input, g.Expected)).ToList(),
|
||||
skill.Body);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,24 @@ using TeamUp.Modules.Skills.Persistence;
|
||||
|
||||
namespace TeamUp.Modules.Skills.Indexing;
|
||||
|
||||
/// <summary>Parses a SKILL.md, computes its embedding, and upserts the Skill row (by key+version).</summary>
|
||||
/// <summary>Who owns an indexed skill row — set once, then immutable for that (org, key, version).</summary>
|
||||
internal readonly record struct SkillOwnership(Guid? OrganizationId, SkillOrigin Origin, Guid? AuthoredByMemberId)
|
||||
{
|
||||
/// <summary>The shared Git starter library: no org, visible to everyone.</summary>
|
||||
public static readonly SkillOwnership Builtin = new(null, SkillOrigin.Builtin, null);
|
||||
|
||||
public static SkillOwnership Authored(Guid organizationId, Guid memberId) =>
|
||||
new(organizationId, SkillOrigin.Authored, memberId);
|
||||
|
||||
public static SkillOwnership Installed(Guid organizationId, Guid memberId) =>
|
||||
new(organizationId, SkillOrigin.Installed, memberId);
|
||||
}
|
||||
|
||||
/// <summary>Parses/projects a skill manifest, computes its embedding, and upserts the row (by org+key+version).</summary>
|
||||
internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder, TimeProvider clock)
|
||||
{
|
||||
public async Task<Skill> IndexAsync(
|
||||
/// <summary>Index raw SKILL.md content from the Git source (a shared builtin).</summary>
|
||||
public Task<Skill> IndexAsync(
|
||||
string content,
|
||||
string? sourceRepo,
|
||||
string? sourcePath,
|
||||
@@ -19,26 +33,51 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var parsed = SkillMarkdownParser.Parse(content);
|
||||
var manifest = parsed.Manifest;
|
||||
var now = clock.GetUtcNow();
|
||||
var contentHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
|
||||
var contentHash = Hash(content);
|
||||
return IndexAsync(parsed.Manifest, parsed.Body, contentHash, SkillOwnership.Builtin, sourceRepo, sourcePath, sourceCommit, cancellationToken);
|
||||
}
|
||||
|
||||
var embeddingText = $"{manifest.Name}\n{manifest.Summary}\n{string.Join(' ', manifest.Roles)}\n{parsed.Body}";
|
||||
/// <summary>Index a manifest authored/installed in-app — same pipeline, org-owned.</summary>
|
||||
public Task<Skill> IndexAsync(
|
||||
SkillManifest manifest,
|
||||
string body,
|
||||
SkillOwnership ownership,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// The content hash spans the structured manifest + body so re-authoring changes it.
|
||||
var canonical = $"{manifest.Id}\n{manifest.Version}\n{manifest.Summary}\n{string.Join(',', manifest.Roles)}\n{body}";
|
||||
return IndexAsync(manifest, body, Hash(canonical), ownership, sourceRepo: null, sourcePath: null, sourceCommit: null, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Skill> IndexAsync(
|
||||
SkillManifest manifest,
|
||||
string body,
|
||||
string contentHash,
|
||||
SkillOwnership ownership,
|
||||
string? sourceRepo,
|
||||
string? sourcePath,
|
||||
string? sourceCommit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
var embeddingText = $"{manifest.Name}\n{manifest.Summary}\n{string.Join(' ', manifest.Roles)}\n{body}";
|
||||
var embedding = new Vector(embedder.Embed(embeddingText));
|
||||
|
||||
// M2 publish gate (structural): a skill is published only if it declares roles and carries
|
||||
// at least one well-formed golden test. Executing the golden tests against a model — and
|
||||
// gating on edit distance — lands in M4 when the assembler/runtime exists.
|
||||
// Publish gate (structural): a skill is published only if it declares roles and carries at
|
||||
// least one well-formed golden test. Executing the golden tests against a model — and gating
|
||||
// on edit distance — lands in M4 when the assembler/runtime exists.
|
||||
var status = manifest.Roles.Count > 0 && manifest.GoldenTests.Count > 0
|
||||
? SkillStatus.Published
|
||||
: SkillStatus.Draft;
|
||||
|
||||
var skill = await db.Skills
|
||||
.FirstOrDefaultAsync(s => s.SkillKey == manifest.Id && s.Version == manifest.Version, cancellationToken);
|
||||
var skill = await db.Skills.FirstOrDefaultAsync(
|
||||
s => s.OrganizationId == ownership.OrganizationId && s.SkillKey == manifest.Id && s.Version == manifest.Version,
|
||||
cancellationToken);
|
||||
|
||||
var isNew = skill is null;
|
||||
skill ??= Skill.Create(manifest.Id, manifest.Version, now);
|
||||
skill.Index(manifest, parsed.Body, contentHash, sourceRepo, sourcePath, sourceCommit, embedding, status, now);
|
||||
skill ??= Skill.Create(manifest.Id, manifest.Version, ownership.OrganizationId, now);
|
||||
skill.Index(manifest, body, contentHash, ownership.Origin, ownership.AuthoredByMemberId, sourceRepo, sourcePath, sourceCommit, embedding, status, now);
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
@@ -48,4 +87,7 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return skill;
|
||||
}
|
||||
|
||||
private static string Hash(string content) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
|
||||
}
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Pgvector;
|
||||
using TeamUp.Modules.Skills.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.Skills.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(SkillsDbContext))]
|
||||
[Migration("20260610180442_AddSkillOwnership")]
|
||||
partial class AddSkillOwnership
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("skills")
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.Skills.Domain.Skill", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AuthoredByMemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContentHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Context")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<Vector>("Embedding")
|
||||
.HasColumnType("vector(384)");
|
||||
|
||||
b.Property<DateTimeOffset>("IndexedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Inputs")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<string>("MinTier")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid?>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Origin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Outputs")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Roles")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<string>("SkillKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("SourceCommit")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourcePath")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceRepo")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Tools")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Version")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Visibility")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("OrganizationId", "SkillKey", "Version")
|
||||
.IsUnique();
|
||||
|
||||
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "SkillKey", "Version"), false);
|
||||
|
||||
b.ToTable("skills", "skills");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.Skills.Domain.Skill", b =>
|
||||
{
|
||||
b.OwnsMany("TeamUp.Modules.Skills.Domain.GoldenExample", "GoldenTests", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("SkillId");
|
||||
|
||||
b1.Property<int>("__synthesizedOrdinal")
|
||||
.ValueGeneratedOnAdd();
|
||||
|
||||
b1.Property<string>("Expected")
|
||||
.IsRequired();
|
||||
|
||||
b1.Property<string>("Input")
|
||||
.IsRequired();
|
||||
|
||||
b1.HasKey("SkillId", "__synthesizedOrdinal");
|
||||
|
||||
b1.ToTable("skills", "skills");
|
||||
|
||||
b1
|
||||
.ToJson("GoldenTests")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("SkillId");
|
||||
});
|
||||
|
||||
b.OwnsMany("TeamUp.Modules.Skills.Domain.SkillAction", "Actions", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("SkillId");
|
||||
|
||||
b1.Property<int>("__synthesizedOrdinal")
|
||||
.ValueGeneratedOnAdd();
|
||||
|
||||
b1.Property<string>("Description");
|
||||
|
||||
b1.Property<string>("Name")
|
||||
.IsRequired();
|
||||
|
||||
b1.Property<int>("Risk");
|
||||
|
||||
b1.HasKey("SkillId", "__synthesizedOrdinal");
|
||||
|
||||
b1.ToTable("skills", "skills");
|
||||
|
||||
b1
|
||||
.ToJson("Actions")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("SkillId");
|
||||
});
|
||||
|
||||
b.Navigation("Actions");
|
||||
|
||||
b.Navigation("GoldenTests");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.Skills.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSkillOwnership : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_skills_SkillKey_Version",
|
||||
schema: "skills",
|
||||
table: "skills");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "AuthoredByMemberId",
|
||||
schema: "skills",
|
||||
table: "skills",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "OrganizationId",
|
||||
schema: "skills",
|
||||
table: "skills",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
// Every pre-existing row came from Git sync, so backfill them as Builtin (an empty
|
||||
// string wouldn't parse back to the SkillOrigin enum). New rows always set Origin.
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Origin",
|
||||
schema: "skills",
|
||||
table: "skills",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "Builtin");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_skills_OrganizationId",
|
||||
schema: "skills",
|
||||
table: "skills",
|
||||
column: "OrganizationId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_skills_OrganizationId_SkillKey_Version",
|
||||
schema: "skills",
|
||||
table: "skills",
|
||||
columns: new[] { "OrganizationId", "SkillKey", "Version" },
|
||||
unique: true)
|
||||
.Annotation("Npgsql:NullsDistinct", false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_skills_OrganizationId",
|
||||
schema: "skills",
|
||||
table: "skills");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_skills_OrganizationId_SkillKey_Version",
|
||||
schema: "skills",
|
||||
table: "skills");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AuthoredByMemberId",
|
||||
schema: "skills",
|
||||
table: "skills");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OrganizationId",
|
||||
schema: "skills",
|
||||
table: "skills");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Origin",
|
||||
schema: "skills",
|
||||
table: "skills");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_skills_SkillKey_Version",
|
||||
schema: "skills",
|
||||
table: "skills",
|
||||
columns: new[] { "SkillKey", "Version" },
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -31,6 +31,9 @@ namespace TeamUp.Modules.Skills.Persistence.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AuthoredByMemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
@@ -64,6 +67,14 @@ namespace TeamUp.Modules.Skills.Persistence.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid?>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Origin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<string>("Outputs")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
@@ -114,11 +125,15 @@ namespace TeamUp.Modules.Skills.Persistence.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("SkillKey", "Version")
|
||||
b.HasIndex("OrganizationId", "SkillKey", "Version")
|
||||
.IsUnique();
|
||||
|
||||
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "SkillKey", "Version"), false);
|
||||
|
||||
b.ToTable("skills", "skills");
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ internal sealed class SkillsDbContext(DbContextOptions<SkillsDbContext> options)
|
||||
{
|
||||
skill.ToTable("skills");
|
||||
skill.HasKey(s => s.Id);
|
||||
skill.Property(s => s.Origin).HasConversion<string>().HasMaxLength(20);
|
||||
skill.Property(s => s.SkillKey).HasMaxLength(128).IsRequired();
|
||||
skill.Property(s => s.Name).HasMaxLength(200).IsRequired();
|
||||
skill.Property(s => s.Version).HasMaxLength(32).IsRequired();
|
||||
@@ -33,7 +34,12 @@ internal sealed class SkillsDbContext(DbContextOptions<SkillsDbContext> options)
|
||||
skill.OwnsMany(s => s.Actions, owned => owned.ToJson());
|
||||
skill.OwnsMany(s => s.GoldenTests, owned => owned.ToJson());
|
||||
|
||||
skill.HasIndex(s => new { s.SkillKey, s.Version }).IsUnique();
|
||||
// Identity is org-scoped: an org owns its (key, version); builtins share the null-org
|
||||
// namespace. NULLS NOT DISTINCT so two builtins can't collide on (null, key, version).
|
||||
skill.HasIndex(s => new { s.OrganizationId, s.SkillKey, s.Version })
|
||||
.IsUnique()
|
||||
.AreNullsDistinct(false);
|
||||
skill.HasIndex(s => s.OrganizationId);
|
||||
skill.HasIndex(s => s.Status);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace TeamUp.Modules.Skills;
|
||||
|
||||
/// <summary>
|
||||
/// Platform-operator settings for managing the shared builtin skill library (null-org skills,
|
||||
/// visible to every tenant). Builtin management is NOT a tenant action — the endpoints that
|
||||
/// create/sync builtins require <see cref="AdminKey"/>, which no tenant role grants.
|
||||
/// </summary>
|
||||
internal sealed class SkillAdminOptions
|
||||
{
|
||||
public const string SectionName = "Skills";
|
||||
|
||||
/// <summary>Operator key required to manage builtins. Null/empty ⇒ builtin management is disabled.</summary>
|
||||
public string? AdminKey { get; set; }
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public sealed class SkillsModule : IModule
|
||||
var connectionString = configuration.GetConnectionString("Postgres")
|
||||
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
|
||||
|
||||
services.Configure<SkillAdminOptions>(configuration.GetSection(SkillAdminOptions.SectionName));
|
||||
services.AddDbContext<SkillsDbContext>(options => options.UseNpgsql(connectionString, npgsql => npgsql.UseVector()));
|
||||
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<SkillsDbContext>());
|
||||
services.AddSingleton<ISkillEmbedder, HashingSkillEmbedder>();
|
||||
|
||||
@@ -14,6 +14,7 @@ public static class AccessPolicy
|
||||
Capability.InvitePeople
|
||||
or Capability.CreateProductsAndTeams
|
||||
or Capability.ConfigureAgents
|
||||
or Capability.ManageSkills
|
||||
or Capability.SetAutonomy
|
||||
or Capability.ApproveHeldActions
|
||||
or Capability.WorkTasks
|
||||
|
||||
@@ -11,6 +11,7 @@ public enum Capability
|
||||
InvitePeople,
|
||||
CreateProductsAndTeams,
|
||||
ConfigureAgents,
|
||||
ManageSkills,
|
||||
SetAutonomy,
|
||||
ApproveHeldActions,
|
||||
WorkTasks,
|
||||
|
||||
@@ -122,6 +122,7 @@ public sealed class AnyRoleSeatTests(PostgresFixture postgres) : IClassFixture<P
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
|
||||
return client;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ public sealed class AssemblerRunTests(PostgresFixture postgres) : IClassFixture<
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token);
|
||||
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
|
||||
|
||||
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" });
|
||||
|
||||
@@ -236,6 +236,7 @@ public sealed class ReviewFlowTests(PostgresFixture postgres) : IClassFixture<Po
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
|
||||
return client;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace TeamUp.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// The dynamic per-company skill library: an org authors a skill, versions it, and forks a builtin —
|
||||
/// all org-scoped (own + shared builtins visible, gated by ManageSkills), with the publish gate intact.
|
||||
/// </summary>
|
||||
public sealed class SkillLibraryTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
||||
{
|
||||
private const string BuiltinSkill =
|
||||
"""
|
||||
---
|
||||
id: spec-writing
|
||||
name: Spec Writing
|
||||
version: 1.0.0
|
||||
summary: Turn a request into a spec.
|
||||
roles: [product-owner]
|
||||
actions:
|
||||
- name: write-spec
|
||||
risk: draft
|
||||
golden_tests:
|
||||
- input: "Add a logout button"
|
||||
expected: "A logout button in the header that ends the session."
|
||||
---
|
||||
# Spec Writing
|
||||
Write a clear, testable spec.
|
||||
""";
|
||||
|
||||
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
|
||||
|
||||
private sealed record AuthResponse(string Token, Guid MemberId);
|
||||
|
||||
private sealed record InviteResponse(Guid InvitationId, string Token);
|
||||
|
||||
private sealed record ActionDto(string Name, string Risk, string? Description);
|
||||
|
||||
private sealed record GoldenTestDto(string Input, string Expected);
|
||||
|
||||
private sealed record SkillSummary(
|
||||
string SkillKey, string Name, string Version, string? Summary, List<string> Roles,
|
||||
string Visibility, string MinTier, string Status, string Origin, Guid? OrganizationId,
|
||||
int GoldenTestCount, List<ActionDto> Actions);
|
||||
|
||||
private sealed record SkillDetail(
|
||||
SkillSummary Skill, string? Inputs, string? Outputs, List<string> Tools,
|
||||
List<string> Context, List<GoldenTestDto> GoldenTests, string Body);
|
||||
|
||||
[Fact]
|
||||
public async Task Org_authors_versions_and_forks_skills_scoped_to_itself()
|
||||
{
|
||||
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
|
||||
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);
|
||||
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
|
||||
|
||||
// A builtin exists in the shared (null-org) namespace.
|
||||
await PostOk<SkillDetail>(client, "/api/skills/index", new { content = BuiltinSkill });
|
||||
|
||||
// The org authors its own skill. Roles + a golden test → Published.
|
||||
var authored = await PostOk<SkillDetail>(client, "/api/skills/authored", new
|
||||
{
|
||||
organizationId = owner.OrganizationId,
|
||||
skillKey = "api-design",
|
||||
name = "API Design",
|
||||
version = "1.0.0",
|
||||
summary = "Design an endpoint.",
|
||||
roles = new[] { "engineer" },
|
||||
inputs = "A story.",
|
||||
outputs = "Route + shapes.",
|
||||
actions = new[] { new { name = "write-design", risk = "draft", description = "Emit a design." } },
|
||||
tools = Array.Empty<string>(),
|
||||
context = Array.Empty<string>(),
|
||||
visibility = "private",
|
||||
minTier = "free",
|
||||
body = "You are the engineer. Design the endpoint.",
|
||||
goldenTests = new[] { new { input = "Delete own comment", expected = "DELETE /comments/{id} 204/403/404" } },
|
||||
});
|
||||
Assert.Equal("Published", authored.Skill.Status);
|
||||
Assert.Equal("Authored", authored.Skill.Origin);
|
||||
Assert.Equal(owner.OrganizationId, authored.Skill.OrganizationId);
|
||||
|
||||
// Bump the version → a new row; both coexist.
|
||||
await PostOk<SkillDetail>(client, "/api/skills/authored", new
|
||||
{
|
||||
organizationId = owner.OrganizationId,
|
||||
skillKey = "api-design",
|
||||
name = "API Design",
|
||||
version = "1.1.0",
|
||||
summary = "Design an endpoint (v2).",
|
||||
roles = new[] { "engineer" },
|
||||
inputs = (string?)null,
|
||||
outputs = (string?)null,
|
||||
actions = Array.Empty<object>(),
|
||||
tools = Array.Empty<string>(),
|
||||
context = Array.Empty<string>(),
|
||||
visibility = "private",
|
||||
minTier = "free",
|
||||
body = "Refined.",
|
||||
goldenTests = new[] { new { input = "x", expected = "y" } },
|
||||
});
|
||||
var versions = await client.GetFromJsonAsync<List<SkillDetail>>(
|
||||
$"/api/skills/api-design?organizationId={owner.OrganizationId}");
|
||||
Assert.Equal(2, versions!.Count);
|
||||
|
||||
// Without roles or golden tests → Draft (publish gate holds).
|
||||
var draft = await PostOk<SkillDetail>(client, "/api/skills/authored", new
|
||||
{
|
||||
organizationId = owner.OrganizationId,
|
||||
skillKey = "rough-idea",
|
||||
name = "Rough Idea",
|
||||
version = "0.1.0",
|
||||
summary = (string?)null,
|
||||
roles = Array.Empty<string>(),
|
||||
inputs = (string?)null,
|
||||
outputs = (string?)null,
|
||||
actions = Array.Empty<object>(),
|
||||
tools = Array.Empty<string>(),
|
||||
context = Array.Empty<string>(),
|
||||
visibility = "private",
|
||||
minTier = "free",
|
||||
body = "WIP.",
|
||||
goldenTests = Array.Empty<object>(),
|
||||
});
|
||||
Assert.Equal("Draft", draft.Skill.Status);
|
||||
|
||||
// The library lists builtins + own skills; another org sees only builtins.
|
||||
var lib = await client.GetFromJsonAsync<List<SkillSummary>>($"/api/skills?organizationId={owner.OrganizationId}");
|
||||
Assert.Contains(lib!, s => s.SkillKey == "spec-writing" && s.Origin == "Builtin");
|
||||
Assert.Contains(lib!, s => s.SkillKey == "api-design" && s.OrganizationId == owner.OrganizationId);
|
||||
|
||||
// Fork the builtin into the org → an editable Authored copy under the org namespace.
|
||||
var forked = await PostOk<SkillDetail>(client, "/api/skills/spec-writing/fork", new
|
||||
{
|
||||
organizationId = owner.OrganizationId,
|
||||
version = "1.0.0",
|
||||
name = (string?)null,
|
||||
});
|
||||
Assert.Equal("Authored", forked.Skill.Origin);
|
||||
Assert.Equal(owner.OrganizationId, forked.Skill.OrganizationId);
|
||||
Assert.Equal("spec-writing", forked.Skill.SkillKey);
|
||||
|
||||
// GET for the key now returns the org's fork AND the builtin.
|
||||
var specVersions = await client.GetFromJsonAsync<List<SkillDetail>>(
|
||||
$"/api/skills/spec-writing?organizationId={owner.OrganizationId}");
|
||||
Assert.Contains(specVersions!, s => s.Skill.OrganizationId == owner.OrganizationId);
|
||||
Assert.Contains(specVersions!, s => s.Skill.OrganizationId == null);
|
||||
|
||||
// A plain Member cannot author skills (ManageSkills is owner/team-owner).
|
||||
var invite = await PostOk<InviteResponse>(client, "/api/identity/invitations", new
|
||||
{
|
||||
email = "member@alia.test",
|
||||
scopeType = "Organization",
|
||||
scopeId = owner.OrganizationId,
|
||||
role = "Member",
|
||||
organizationId = owner.OrganizationId,
|
||||
});
|
||||
var member = await PostOk<AuthResponse>(anon, "/api/identity/invitations/accept",
|
||||
new { token = invite.Token, displayName = "Member", password = "Passw0rd!" });
|
||||
using var memberClient = Authed(factory, member.Token);
|
||||
var forbidden = await memberClient.PostAsJsonAsync("/api/skills/authored", new
|
||||
{
|
||||
organizationId = owner.OrganizationId,
|
||||
skillKey = "sneaky",
|
||||
name = "Sneaky",
|
||||
version = "1.0.0",
|
||||
roles = Array.Empty<string>(),
|
||||
actions = Array.Empty<object>(),
|
||||
tools = Array.Empty<string>(),
|
||||
context = Array.Empty<string>(),
|
||||
visibility = "private",
|
||||
minTier = "free",
|
||||
body = "no",
|
||||
goldenTests = Array.Empty<object>(),
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
|
||||
|
||||
// Builtin management (shared null-org skills) needs the platform admin key — an authenticated
|
||||
// tenant user without it cannot inject or re-sync builtins, even as Owner.
|
||||
using var noKey = Authed(factory, owner.Token);
|
||||
Assert.Equal(HttpStatusCode.Forbidden,
|
||||
(await noKey.PostAsJsonAsync("/api/skills/index", new { content = BuiltinSkill })).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden,
|
||||
(await noKey.PostAsync("/api/skills/sync", content: null)).StatusCode);
|
||||
}
|
||||
|
||||
private static HttpClient Authed(TeamUpWebFactory factory, string token)
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
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!;
|
||||
}
|
||||
}
|
||||
@@ -42,15 +42,18 @@ public sealed class SkillRegistryTests(PostgresFixture postgres) : IClassFixture
|
||||
|
||||
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
|
||||
|
||||
private sealed record ActionDto(string Name, string Risk);
|
||||
private sealed record ActionDto(string Name, string Risk, string? Description);
|
||||
|
||||
private sealed record GoldenTestDto(string Input, string Expected);
|
||||
|
||||
private sealed record SkillSummary(
|
||||
string SkillKey, string Name, string Version, string? Summary, List<string> Roles,
|
||||
string Visibility, string MinTier, string Status, List<ActionDto> Actions);
|
||||
string Visibility, string MinTier, string Status, string Origin, Guid? OrganizationId,
|
||||
int GoldenTestCount, List<ActionDto> Actions);
|
||||
|
||||
private sealed record SkillDetail(
|
||||
SkillSummary Skill, string? Inputs, string? Outputs, List<string> Tools,
|
||||
List<string> Context, int GoldenTestCount, string Body);
|
||||
List<string> Context, List<GoldenTestDto> GoldenTests, string Body);
|
||||
|
||||
[Fact]
|
||||
public async Task Index_publishes_and_makes_skill_queryable_by_role()
|
||||
@@ -73,6 +76,7 @@ public sealed class SkillRegistryTests(PostgresFixture postgres) : IClassFixture
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token);
|
||||
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
|
||||
|
||||
// Index the SKILL.md.
|
||||
var indexResponse = await client.PostAsJsonAsync("/api/skills/index", new { content = SpecWritingSkill });
|
||||
@@ -81,7 +85,9 @@ public sealed class SkillRegistryTests(PostgresFixture postgres) : IClassFixture
|
||||
Assert.NotNull(indexed);
|
||||
Assert.Equal("spec-writing", indexed!.Skill.SkillKey);
|
||||
Assert.Equal("Published", indexed.Skill.Status); // has roles + a golden test
|
||||
Assert.Equal(1, indexed.GoldenTestCount);
|
||||
Assert.Equal("Builtin", indexed.Skill.Origin);
|
||||
Assert.Null(indexed.Skill.OrganizationId);
|
||||
Assert.Single(indexed.GoldenTests);
|
||||
Assert.Contains(indexed.Skill.Actions, a => a.Name == "write-spec" && a.Risk == "Draft");
|
||||
|
||||
// Queryable by its role…
|
||||
|
||||
@@ -45,6 +45,7 @@ public sealed class SkillSyncTests(PostgresFixture postgres) : IClassFixture<Pos
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token);
|
||||
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
|
||||
|
||||
var syncResponse = await client.PostAsync("/api/skills/sync", content: null);
|
||||
Assert.Equal(HttpStatusCode.OK, syncResponse.StatusCode);
|
||||
|
||||
@@ -11,12 +11,17 @@ public sealed class TeamUpWebFactory(
|
||||
string connectionString,
|
||||
IReadOnlyDictionary<string, string?>? settings = null) : WebApplicationFactory<Program>
|
||||
{
|
||||
/// <summary>Operator key the test host accepts for builtin management (/index, /sync).</summary>
|
||||
public const string PlatformAdminKey = "test-admin-key";
|
||||
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
builder.UseSetting("ConnectionStrings:Postgres", connectionString);
|
||||
builder.UseSetting("Database:ApplyMigrationsOnStartup", "true");
|
||||
builder.UseSetting("OpenTelemetry:OtlpEndpoint", string.Empty);
|
||||
builder.UseSetting("Skills:AdminKey", PlatformAdminKey);
|
||||
|
||||
if (settings is not null)
|
||||
{
|
||||
|
||||
@@ -232,6 +232,7 @@ public sealed class TwoRoleLoopTests(PostgresFixture postgres) : IClassFixture<P
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
|
||||
return client;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user