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 { PerformancePage } from '@/pages/PerformancePage'
|
||||||
import { ReviewsPage } from '@/pages/ReviewsPage'
|
import { ReviewsPage } from '@/pages/ReviewsPage'
|
||||||
import { SeatsPage } from '@/pages/SeatsPage'
|
import { SeatsPage } from '@/pages/SeatsPage'
|
||||||
|
import { SkillsPage } from '@/pages/SkillsPage'
|
||||||
import { StructurePage } from '@/pages/StructurePage'
|
import { StructurePage } from '@/pages/StructurePage'
|
||||||
import { useAuth } from '@/store/auth'
|
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="/members" element={token ? <MembersPage /> : <Navigate to="/login" replace />} />
|
||||||
<Route path="/org" element={token ? <OrgChartPage /> : <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="/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="/performance" element={token ? <PerformancePage /> : <Navigate to="/login" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Link, useLocation } from 'react-router'
|
import { Link, useLocation } from 'react-router'
|
||||||
import {
|
import {
|
||||||
|
BookMarked,
|
||||||
Bot,
|
Bot,
|
||||||
Boxes,
|
Boxes,
|
||||||
ChartColumn,
|
ChartColumn,
|
||||||
@@ -42,6 +43,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<NavItem icon={Inbox} label="Cartable" to="/cartable" />
|
<NavItem icon={Inbox} label="Cartable" to="/cartable" />
|
||||||
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
|
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
|
||||||
<NavItem icon={Bot} label="AI seats" to="/seats" />
|
<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={Network} label="Org chart" to="/org" />
|
||||||
<NavItem icon={Boxes} label="Structure" to="/structure" />
|
<NavItem icon={Boxes} label="Structure" to="/structure" />
|
||||||
<NavItem icon={Users} label="Members" to="/members" />
|
<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>
|
/// </summary>
|
||||||
internal sealed class Skill : Entity
|
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 SkillKey { get; private set; } = null!;
|
||||||
public string Name { get; private set; } = null!;
|
public string Name { get; private set; } = null!;
|
||||||
public string Version { 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) =>
|
public static Skill Create(string skillKey, string version, Guid? organizationId, DateTimeOffset nowUtc) =>
|
||||||
new() { SkillKey = skillKey, Version = version, IndexedAtUtc = 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>
|
/// <summary>(Re)projects a parsed manifest + body onto this row. Used for both insert and update.</summary>
|
||||||
public void Index(
|
public void Index(
|
||||||
SkillManifest manifest,
|
SkillManifest manifest,
|
||||||
string body,
|
string body,
|
||||||
string contentHash,
|
string contentHash,
|
||||||
|
SkillOrigin origin,
|
||||||
|
Guid? authoredByMemberId,
|
||||||
string? sourceRepo,
|
string? sourceRepo,
|
||||||
string? sourcePath,
|
string? sourcePath,
|
||||||
string? sourceCommit,
|
string? sourceCommit,
|
||||||
@@ -51,6 +57,8 @@ internal sealed class Skill : Entity
|
|||||||
SkillStatus status,
|
SkillStatus status,
|
||||||
DateTimeOffset nowUtc)
|
DateTimeOffset nowUtc)
|
||||||
{
|
{
|
||||||
|
Origin = origin;
|
||||||
|
AuthoredByMemberId = authoredByMemberId;
|
||||||
Name = string.IsNullOrWhiteSpace(manifest.Name) ? manifest.Id : manifest.Name;
|
Name = string.IsNullOrWhiteSpace(manifest.Name) ? manifest.Id : manifest.Name;
|
||||||
Version = manifest.Version;
|
Version = manifest.Version;
|
||||||
Summary = manifest.Summary;
|
Summary = manifest.Summary;
|
||||||
@@ -62,7 +70,11 @@ internal sealed class Skill : Entity
|
|||||||
.ToList();
|
.ToList();
|
||||||
Tools = manifest.Tools;
|
Tools = manifest.Tools;
|
||||||
Context = manifest.Context;
|
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);
|
Visibility = ParseVisibility(manifest.Visibility);
|
||||||
MinTier = ParseTier(manifest.MinTier);
|
MinTier = ParseTier(manifest.MinTier);
|
||||||
Status = status;
|
Status = status;
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ internal enum SkillStatus
|
|||||||
Published,
|
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>
|
/// <summary>A risk-tagged action a skill can take. Stored as JSON on the skill.</summary>
|
||||||
internal sealed class SkillAction
|
internal sealed class SkillAction
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
namespace TeamUp.Modules.Skills.Endpoints;
|
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(
|
internal sealed record SkillSummary(
|
||||||
string SkillKey,
|
string SkillKey,
|
||||||
@@ -11,6 +13,9 @@ internal sealed record SkillSummary(
|
|||||||
string Visibility,
|
string Visibility,
|
||||||
string MinTier,
|
string MinTier,
|
||||||
string Status,
|
string Status,
|
||||||
|
string Origin,
|
||||||
|
Guid? OrganizationId,
|
||||||
|
int GoldenTestCount,
|
||||||
List<ActionDto> Actions);
|
List<ActionDto> Actions);
|
||||||
|
|
||||||
internal sealed record SkillDetail(
|
internal sealed record SkillDetail(
|
||||||
@@ -19,9 +24,33 @@ internal sealed record SkillDetail(
|
|||||||
string? Outputs,
|
string? Outputs,
|
||||||
List<string> Tools,
|
List<string> Tools,
|
||||||
List<string> Context,
|
List<string> Context,
|
||||||
int GoldenTestCount,
|
List<GoldenTestDto> GoldenTests,
|
||||||
string Body);
|
string Body);
|
||||||
|
|
||||||
internal sealed record IndexRequest(string Content, string? SourceRepo, string? SourcePath, string? SourceCommit);
|
internal sealed record IndexRequest(string Content, string? SourceRepo, string? SourcePath, string? SourceCommit);
|
||||||
|
|
||||||
internal sealed record SyncResult(int Indexed);
|
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.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using TeamUp.Modules.Skills.Domain;
|
using TeamUp.Modules.Skills.Domain;
|
||||||
using TeamUp.Modules.Skills.Indexing;
|
using TeamUp.Modules.Skills.Indexing;
|
||||||
using TeamUp.Modules.Skills.Persistence;
|
using TeamUp.Modules.Skills.Persistence;
|
||||||
using TeamUp.Modules.Skills.Sync;
|
using TeamUp.Modules.Skills.Sync;
|
||||||
|
using TeamUp.SharedKernel.Access;
|
||||||
|
using TeamUp.SharedKernel.Auditing;
|
||||||
using TeamUp.SharedKernel.Modularity;
|
using TeamUp.SharedKernel.Modularity;
|
||||||
|
|
||||||
namespace TeamUp.Modules.Skills.Endpoints;
|
namespace TeamUp.Modules.Skills.Endpoints;
|
||||||
@@ -18,24 +23,64 @@ internal static class SkillsEndpoints
|
|||||||
|
|
||||||
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("skills")));
|
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("skills")));
|
||||||
group.MapGet("/", ListSkills).RequireAuthorization();
|
group.MapGet("/", ListSkills).RequireAuthorization();
|
||||||
|
group.MapGet("/marketplace", Marketplace).RequireAuthorization();
|
||||||
group.MapGet("/{key}", GetSkill).RequireAuthorization();
|
group.MapGet("/{key}", GetSkill).RequireAuthorization();
|
||||||
|
group.MapPost("/authored", AuthorSkill).RequireAuthorization();
|
||||||
|
group.MapPost("/{key}/fork", ForkSkill).RequireAuthorization();
|
||||||
group.MapPost("/index", IndexSkill).RequireAuthorization();
|
group.MapPost("/index", IndexSkill).RequireAuthorization();
|
||||||
group.MapPost("/sync", Sync).RequireAuthorization();
|
group.MapPost("/sync", Sync).RequireAuthorization();
|
||||||
group.MapPost("/webhook/gitea", Webhook).AllowAnonymous();
|
group.MapPost("/webhook/gitea", Webhook).AllowAnonymous();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Sync(SkillSyncService sync, CancellationToken ct) =>
|
// Re-syncing the shared builtin library is an operator action, not a tenant one.
|
||||||
Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
|
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);
|
return Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
|
||||||
// signature verification + changed-file-only sync via the job queue land later.
|
}
|
||||||
|
|
||||||
|
// 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) =>
|
private static async Task<IResult> Webhook(SkillSyncService sync, CancellationToken ct) =>
|
||||||
Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
|
Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
|
||||||
|
|
||||||
private static async Task<IResult> ListSkills(
|
/// <summary>
|
||||||
string? role, string? visibility, SkillsDbContext db, CancellationToken ct)
|
/// 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))
|
if (!string.IsNullOrWhiteSpace(role))
|
||||||
{
|
{
|
||||||
@@ -55,11 +100,30 @@ internal static class SkillsEndpoints
|
|||||||
return Results.Ok(skills.Select(ToSummary).ToList());
|
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
|
var versions = await db.Skills
|
||||||
.Where(s => s.SkillKey == key)
|
.Where(s => s.SkillKey == key && (s.OrganizationId == null || s.OrganizationId == organizationId))
|
||||||
.OrderByDescending(s => s.Version)
|
.OrderByDescending(s => s.OrganizationId != null) // org's own first, then builtins
|
||||||
|
.ThenByDescending(s => s.Version)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return versions.Count == 0
|
return versions.Count == 0
|
||||||
@@ -67,8 +131,75 @@ internal static class SkillsEndpoints
|
|||||||
: Results.Ok(versions.Select(ToDetail).ToList());
|
: 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))
|
if (string.IsNullOrWhiteSpace(request.Content))
|
||||||
{
|
{
|
||||||
return Results.BadRequest("content is required.");
|
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(
|
private static SkillSummary ToSummary(Skill skill) => new(
|
||||||
skill.SkillKey,
|
skill.SkillKey,
|
||||||
skill.Name,
|
skill.Name,
|
||||||
@@ -95,7 +266,10 @@ internal static class SkillsEndpoints
|
|||||||
skill.Visibility.ToString(),
|
skill.Visibility.ToString(),
|
||||||
skill.MinTier.ToString(),
|
skill.MinTier.ToString(),
|
||||||
skill.Status.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(
|
private static SkillDetail ToDetail(Skill skill) => new(
|
||||||
ToSummary(skill),
|
ToSummary(skill),
|
||||||
@@ -103,6 +277,6 @@ internal static class SkillsEndpoints
|
|||||||
skill.Outputs,
|
skill.Outputs,
|
||||||
skill.Tools,
|
skill.Tools,
|
||||||
skill.Context,
|
skill.Context,
|
||||||
skill.GoldenTests.Count,
|
skill.GoldenTests.Select(g => new GoldenTestDto(g.Input, g.Expected)).ToList(),
|
||||||
skill.Body);
|
skill.Body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,24 @@ using TeamUp.Modules.Skills.Persistence;
|
|||||||
|
|
||||||
namespace TeamUp.Modules.Skills.Indexing;
|
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)
|
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 content,
|
||||||
string? sourceRepo,
|
string? sourceRepo,
|
||||||
string? sourcePath,
|
string? sourcePath,
|
||||||
@@ -19,26 +33,51 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var parsed = SkillMarkdownParser.Parse(content);
|
var parsed = SkillMarkdownParser.Parse(content);
|
||||||
var manifest = parsed.Manifest;
|
var contentHash = Hash(content);
|
||||||
var now = clock.GetUtcNow();
|
return IndexAsync(parsed.Manifest, parsed.Body, contentHash, SkillOwnership.Builtin, sourceRepo, sourcePath, sourceCommit, cancellationToken);
|
||||||
var contentHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
|
}
|
||||||
|
|
||||||
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));
|
var embedding = new Vector(embedder.Embed(embeddingText));
|
||||||
|
|
||||||
// M2 publish gate (structural): a skill is published only if it declares roles and carries
|
// Publish gate (structural): a skill is published only if it declares roles and carries at
|
||||||
// at least one well-formed golden test. Executing the golden tests against a model — and
|
// least one well-formed golden test. Executing the golden tests against a model — and gating
|
||||||
// gating on edit distance — lands in M4 when the assembler/runtime exists.
|
// on edit distance — lands in M4 when the assembler/runtime exists.
|
||||||
var status = manifest.Roles.Count > 0 && manifest.GoldenTests.Count > 0
|
var status = manifest.Roles.Count > 0 && manifest.GoldenTests.Count > 0
|
||||||
? SkillStatus.Published
|
? SkillStatus.Published
|
||||||
: SkillStatus.Draft;
|
: SkillStatus.Draft;
|
||||||
|
|
||||||
var skill = await db.Skills
|
var skill = await db.Skills.FirstOrDefaultAsync(
|
||||||
.FirstOrDefaultAsync(s => s.SkillKey == manifest.Id && s.Version == manifest.Version, cancellationToken);
|
s => s.OrganizationId == ownership.OrganizationId && s.SkillKey == manifest.Id && s.Version == manifest.Version,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
var isNew = skill is null;
|
var isNew = skill is null;
|
||||||
skill ??= Skill.Create(manifest.Id, manifest.Version, now);
|
skill ??= Skill.Create(manifest.Id, manifest.Version, ownership.OrganizationId, now);
|
||||||
skill.Index(manifest, parsed.Body, contentHash, sourceRepo, sourcePath, sourceCommit, embedding, status, now);
|
skill.Index(manifest, body, contentHash, ownership.Origin, ownership.AuthoredByMemberId, sourceRepo, sourcePath, sourceCommit, embedding, status, now);
|
||||||
|
|
||||||
if (isNew)
|
if (isNew)
|
||||||
{
|
{
|
||||||
@@ -48,4 +87,7 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
|
|||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return skill;
|
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()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AuthoredByMemberId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("Body")
|
b.Property<string>("Body")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -64,6 +67,14 @@ namespace TeamUp.Modules.Skills.Persistence.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(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")
|
b.Property<string>("Outputs")
|
||||||
.HasMaxLength(2000)
|
.HasMaxLength(2000)
|
||||||
.HasColumnType("character varying(2000)");
|
.HasColumnType("character varying(2000)");
|
||||||
@@ -114,11 +125,15 @@ namespace TeamUp.Modules.Skills.Persistence.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId");
|
||||||
|
|
||||||
b.HasIndex("Status");
|
b.HasIndex("Status");
|
||||||
|
|
||||||
b.HasIndex("SkillKey", "Version")
|
b.HasIndex("OrganizationId", "SkillKey", "Version")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "SkillKey", "Version"), false);
|
||||||
|
|
||||||
b.ToTable("skills", "skills");
|
b.ToTable("skills", "skills");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ internal sealed class SkillsDbContext(DbContextOptions<SkillsDbContext> options)
|
|||||||
{
|
{
|
||||||
skill.ToTable("skills");
|
skill.ToTable("skills");
|
||||||
skill.HasKey(s => s.Id);
|
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.SkillKey).HasMaxLength(128).IsRequired();
|
||||||
skill.Property(s => s.Name).HasMaxLength(200).IsRequired();
|
skill.Property(s => s.Name).HasMaxLength(200).IsRequired();
|
||||||
skill.Property(s => s.Version).HasMaxLength(32).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.Actions, owned => owned.ToJson());
|
||||||
skill.OwnsMany(s => s.GoldenTests, 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);
|
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")
|
var connectionString = configuration.GetConnectionString("Postgres")
|
||||||
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings: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.AddDbContext<SkillsDbContext>(options => options.UseNpgsql(connectionString, npgsql => npgsql.UseVector()));
|
||||||
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<SkillsDbContext>());
|
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<SkillsDbContext>());
|
||||||
services.AddSingleton<ISkillEmbedder, HashingSkillEmbedder>();
|
services.AddSingleton<ISkillEmbedder, HashingSkillEmbedder>();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public static class AccessPolicy
|
|||||||
Capability.InvitePeople
|
Capability.InvitePeople
|
||||||
or Capability.CreateProductsAndTeams
|
or Capability.CreateProductsAndTeams
|
||||||
or Capability.ConfigureAgents
|
or Capability.ConfigureAgents
|
||||||
|
or Capability.ManageSkills
|
||||||
or Capability.SetAutonomy
|
or Capability.SetAutonomy
|
||||||
or Capability.ApproveHeldActions
|
or Capability.ApproveHeldActions
|
||||||
or Capability.WorkTasks
|
or Capability.WorkTasks
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public enum Capability
|
|||||||
InvitePeople,
|
InvitePeople,
|
||||||
CreateProductsAndTeams,
|
CreateProductsAndTeams,
|
||||||
ConfigureAgents,
|
ConfigureAgents,
|
||||||
|
ManageSkills,
|
||||||
SetAutonomy,
|
SetAutonomy,
|
||||||
ApproveHeldActions,
|
ApproveHeldActions,
|
||||||
WorkTasks,
|
WorkTasks,
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ public sealed class AnyRoleSeatTests(PostgresFixture postgres) : IClassFixture<P
|
|||||||
{
|
{
|
||||||
var client = factory.CreateClient();
|
var client = factory.CreateClient();
|
||||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ public sealed class AssemblerRunTests(PostgresFixture postgres) : IClassFixture<
|
|||||||
|
|
||||||
using var client = factory.CreateClient();
|
using var client = factory.CreateClient();
|
||||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token);
|
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" });
|
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 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();
|
var client = factory.CreateClient();
|
||||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
|
||||||
return client;
|
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 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(
|
private sealed record SkillSummary(
|
||||||
string SkillKey, string Name, string Version, string? Summary, List<string> Roles,
|
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(
|
private sealed record SkillDetail(
|
||||||
SkillSummary Skill, string? Inputs, string? Outputs, List<string> Tools,
|
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]
|
[Fact]
|
||||||
public async Task Index_publishes_and_makes_skill_queryable_by_role()
|
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();
|
using var client = factory.CreateClient();
|
||||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token);
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token);
|
||||||
|
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
|
||||||
|
|
||||||
// Index the SKILL.md.
|
// Index the SKILL.md.
|
||||||
var indexResponse = await client.PostAsJsonAsync("/api/skills/index", new { content = SpecWritingSkill });
|
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.NotNull(indexed);
|
||||||
Assert.Equal("spec-writing", indexed!.Skill.SkillKey);
|
Assert.Equal("spec-writing", indexed!.Skill.SkillKey);
|
||||||
Assert.Equal("Published", indexed.Skill.Status); // has roles + a golden test
|
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");
|
Assert.Contains(indexed.Skill.Actions, a => a.Name == "write-spec" && a.Risk == "Draft");
|
||||||
|
|
||||||
// Queryable by its role…
|
// Queryable by its role…
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public sealed class SkillSyncTests(PostgresFixture postgres) : IClassFixture<Pos
|
|||||||
|
|
||||||
using var client = factory.CreateClient();
|
using var client = factory.CreateClient();
|
||||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token);
|
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);
|
var syncResponse = await client.PostAsync("/api/skills/sync", content: null);
|
||||||
Assert.Equal(HttpStatusCode.OK, syncResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, syncResponse.StatusCode);
|
||||||
|
|||||||
@@ -11,12 +11,17 @@ public sealed class TeamUpWebFactory(
|
|||||||
string connectionString,
|
string connectionString,
|
||||||
IReadOnlyDictionary<string, string?>? settings = null) : WebApplicationFactory<Program>
|
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)
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
{
|
{
|
||||||
builder.UseEnvironment("Development");
|
builder.UseEnvironment("Development");
|
||||||
builder.UseSetting("ConnectionStrings:Postgres", connectionString);
|
builder.UseSetting("ConnectionStrings:Postgres", connectionString);
|
||||||
builder.UseSetting("Database:ApplyMigrationsOnStartup", "true");
|
builder.UseSetting("Database:ApplyMigrationsOnStartup", "true");
|
||||||
builder.UseSetting("OpenTelemetry:OtlpEndpoint", string.Empty);
|
builder.UseSetting("OpenTelemetry:OtlpEndpoint", string.Empty);
|
||||||
|
builder.UseSetting("Skills:AdminKey", PlatformAdminKey);
|
||||||
|
|
||||||
if (settings is not null)
|
if (settings is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ public sealed class TwoRoleLoopTests(PostgresFixture postgres) : IClassFixture<P
|
|||||||
{
|
{
|
||||||
var client = factory.CreateClient();
|
var client = factory.CreateClient();
|
||||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user