Compare commits

...

18 Commits

Author SHA1 Message Date
soroush.asadi d6a0638c59 Harden API client against stale-backend HTML fallback
When the running backend is missing a route, the SPA fallback serves index.html
with a 200, and the api helper returned undefined — which crashed pages on
.map/.length (the Delivery pipeline white-screened against an old build). Now a
non-JSON 2xx on an /api call throws a clear 'API is running an older build'
error instead, and PipelinePage defensively coerces its lists to arrays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:33:59 +03:30
soroush.asadi c12935ad74 Theme 2: cross-division delivery pipeline (change requests)
A customer change request now flows through a guarded commercial pipeline:
Requested -> Estimated -> Approved -> Paid -> Live. The cross-division work and
its dependencies live on the request's steps (a division's slice + hours +
an optional depends-on link), and estimating sums the steps into a total. Each
transition is guarded on the ChangeRequest aggregate, so it can only move
forward in order; guard violations surface as 400s.

- Domain: ChangeRequest + ChangeRequestStep aggregates with stage guards
- Persistence: two tables + EF migration (applied)
- Endpoints under /api/orgboard/change-requests: create/list/detail, add/advance
  steps, and estimate/approve/pay/go-live/reject (reads need board-view,
  commercial actions are owner-level)
- New Delivery pipeline page: request list with stage + step progress, a detail
  drawer with a stage stepper, the next commercial action, quote entry, and a
  per-division step breakdown with dependencies

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 07:47:57 +03:30
soroush.asadi 5c2b697b66 Theme 3: autonomous Run all — fan a big task's children across the AI seats
New POST /api/orgboard/tasks/{id}/run-all dispatches a run for every
outstanding child of a task (not done, no artifact yet), round-robined across
the team's configured AI seats, or all to one seat if specified. Agents still
act per their own autonomy, so gated work lands in review. The board task
drawer gets a Run all with AI button on any task that has children, so one
click puts the whole breakdown to work — then watch progress on the Delivery
dashboard and download the result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 07:36:59 +03:30
soroush.asadi 0658061580 Theme 3: download a product as a project (zip export)
New GET /api/orgboard/products/{id}/export streams a zip of the product's
delivered work: PRODUCT.md (identity), each team's artifacts written as real
source files when the artifact is a single fenced code block (App.tsx,
schema.sql, …) or markdown otherwise, plus a README manifest. Gated on
board-view permission. The Delivery dashboard gets a Download project button
that fetches the file with the auth header and saves it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 07:30:37 +03:30
soroush.asadi 1e33d57b4e Live run progress: watch the agent work in real time
Clicking Run on a task now keeps the drawer open and streams the agent's
progress instead of just closing. A new RunProgress panel polls the run
(GET /api/assembler/runs/{id}) and shows a live timeline — Queued -> Thinking
-> Delivered — with elapsed time, any MCP tool calls, the action produced and
its risk tag, and an expandable output. When the run settles it refreshes the
board and pulses the review badge. The panel resets when the drawer switches
tasks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:42:36 +03:30
soroush.asadi 861efa4e20 Delivery dashboard: per-product progress, remaining work, and quality stats
Theme 1 of the roadmap. New /delivery page shows, per product: % complete
with a progress bar, a Backlog/InProgress/InReview/Done column breakdown,
quality stats from governance analytics (approval rate, avg edit distance,
awaiting review), per-team progress bars, and the remaining-task list.
Selected product persists in localStorage. Wired into routing and the
Insights sidebar group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:38:08 +03:30
soroush.asadi e26f304675 Run it in TeamUp: live React preview of a task's artifact
Adds LivePreview — a sandboxed iframe that transpiles an agent's component with Babel and
renders it live with React + Tailwind from CDN (no build step), so a generated page runs
inside TeamUp. A "Preview" button on any task with an artifact opens a full-screen live
view. Pairs with steering the engineer agent to output a single self-contained App component.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 23:35:28 +03:30
soroush.asadi 63fef8799d Tasks visibly progress: Run → In Progress (+ close drawer); approve → In Review
A task assigned to an agent never moved, so it looked like nothing happened. Now:
- Clicking Run moves the task from Backlog to In Progress and closes the drawer.
- When an approved artifact lands on a task, it moves to In Review (unless already Done),
  so the delivered result is visible on the board instead of sitting in Backlog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:38:34 +03:30
soroush.asadi 1f562fd633 Fix: child tasks only from spec/breakdown agents; live review badge
Two fixes from real usage:
- Child-task creation is now gated to story-producing skills (spec-writing,
  story-breakdown). A code/design/test agent's output is the artifact — a numbered list
  in it (e.g. file names from an engineer) is no longer mistaken for child stories.
- The review-inbox badge now updates without a refresh: it polls more often (6s),
  refetches on window focus, and reacts to a REVIEWS_CHANGED event the board fires after
  Run (with a couple of delayed pulses to catch the ~5s completion) and the review page
  fires after approve / send back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:22:57 +03:30
soroush.asadi 9993ebb2b4 Delete tasks from the board
Adds DELETE /api/orgboard/tasks/{id} (WorkTasks permission) and a "Delete task" button
in the task drawer (with confirm). Children are detached (kept as top-level) rather than
deleted; status-transition history is dropped. There was previously no way to remove a task.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:03:14 +03:30
soroush.asadi cb9ce34309 Defensive: prevent duplicate agent runs and stacked review items
Two server-side guards so repeated "Run" clicks can't pile up duplicates:
- Dispatcher: returns the in-flight run if one is already Queued/Running for the same
  (seat, task) instead of dispatching another model call.
- Action gate: if an identical action is already Pending for the same (task, agent),
  it keeps that review item rather than stacking another copy in the inbox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 21:47:04 +03:30
soroush.asadi b398e68c8b Sidebar: pending-review count badge on the Review inbox item
After dispatching an agent run, its proposal goes to the review inbox, but the board
gave no signal — users thought "nothing happened". The Review inbox nav item now shows
an amber count of held actions awaiting review (polled), so waiting work is always visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 21:35:09 +03:30
soroush.asadi 9c18ddfc8f Fix: board remembers the selected team across refresh
The board's selected team was local state that defaulted to the first team on every load,
so a refresh snapped away from the team you were on (and its tasks appeared to vanish).
Now the selection is restored from localStorage, validated against the current teams, and
persisted on change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 21:15:23 +03:30
soroush.asadi 848bd49352 Merge: onboarding, knowledge base, grouped colorful sidebar, and the user manual
Adds an A-to-Z Get started checklist, an in-app Knowledge base (15 searchable how-to articles with examples), a sidebar regrouped by UX with color-coded per-group icons + a vivid glass gradient, and the TeamUp user manual (with the user story) under docs/.
2026-06-16 08:14:49 +03:30
soroush.asadi e24dc49970 docs: add the TeamUp user manual (with the user story)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:14:14 +03:30
soroush.asadi 0496cb1436 Colorful sidebar: per-group icon chips, vivid gradient, gradient logo
The nav was too monochrome. Each item now has a color-coded rounded icon chip (group
palette: amber start, sky Work, emerald Organization, violet AI & libraries, teal
Insights, pink Help), the sidebar uses a richer multi-hue indigo→violet→magenta glass
gradient, and the logo is a gradient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:14:14 +03:30
soroush.asadi c9be692d52 Knowledge base + grouped, reordered sidebar
Adds an in-app Knowledge base (route /help): 15 searchable, expandable how-to articles
with step-by-step guides and examples (concepts, A-to-Z setup, the review inbox, the
handoff + memory, the libraries, analytics, governance, troubleshooting), rendered as
markdown.

Reorganizes the sidebar into UX-ordered groups with section labels — Get started ·
Work (Board/Team/Cartable/Reviews) · Organization (Structure/Org chart/Members) ·
AI & libraries (AI seats/Skills/Agent profiles/Product profiles) · Insights
(Performance/Analytics) · Help (Knowledge base).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 08:06:23 +03:30
soroush.asadi 8a033a2a6f Get started: an A-to-Z onboarding checklist
A new "Get started" page (top of the sidebar) that detects setup progress from real data
and guides the full flow: model the org -> product identity -> connect a model (BYOK) ->
staff an AI seat -> fill the backlog -> review the first agent output. Each step shows
done/todo with a deep link, plus an overall progress bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 07:56:32 +03:30
28 changed files with 3425 additions and 33 deletions
+8
View File
@@ -4,10 +4,14 @@ import { AgentProfilesPage } from '@/pages/AgentProfilesPage'
import { AnalyticsPage } from '@/pages/AnalyticsPage' import { AnalyticsPage } from '@/pages/AnalyticsPage'
import { BoardPage } from '@/pages/BoardPage' import { BoardPage } from '@/pages/BoardPage'
import { CartablePage } from '@/pages/CartablePage' import { CartablePage } from '@/pages/CartablePage'
import { DeliveryPage } from '@/pages/DeliveryPage'
import { GetStartedPage } from '@/pages/GetStartedPage'
import { KnowledgeBasePage } from '@/pages/KnowledgeBasePage'
import { LoginPage } from '@/pages/LoginPage' import { LoginPage } from '@/pages/LoginPage'
import { MembersPage } from '@/pages/MembersPage' import { MembersPage } from '@/pages/MembersPage'
import { OrgChartPage } from '@/pages/OrgChartPage' import { OrgChartPage } from '@/pages/OrgChartPage'
import { PerformancePage } from '@/pages/PerformancePage' import { PerformancePage } from '@/pages/PerformancePage'
import { PipelinePage } from '@/pages/PipelinePage'
import { ProductProfilesPage } from '@/pages/ProductProfilesPage' import { ProductProfilesPage } from '@/pages/ProductProfilesPage'
import { ReviewsPage } from '@/pages/ReviewsPage' import { ReviewsPage } from '@/pages/ReviewsPage'
import { SeatsPage } from '@/pages/SeatsPage' import { SeatsPage } from '@/pages/SeatsPage'
@@ -24,10 +28,13 @@ export default function App() {
<Routes> <Routes>
<Route path="/login" element={token ? <Navigate to="/" replace /> : <LoginPage />} /> <Route path="/login" element={token ? <Navigate to="/" replace /> : <LoginPage />} />
<Route path="/" element={token ? <BoardPage /> : <Navigate to="/login" replace />} /> <Route path="/" element={token ? <BoardPage /> : <Navigate to="/login" replace />} />
<Route path="/start" element={token ? <GetStartedPage /> : <Navigate to="/login" replace />} />
<Route path="/team" element={token ? <TeamPage /> : <Navigate to="/login" replace />} /> <Route path="/team" element={token ? <TeamPage /> : <Navigate to="/login" replace />} />
<Route path="/seats" element={token ? <SeatsPage /> : <Navigate to="/login" replace />} /> <Route path="/seats" element={token ? <SeatsPage /> : <Navigate to="/login" replace />} />
<Route path="/reviews" element={token ? <ReviewsPage /> : <Navigate to="/login" replace />} /> <Route path="/reviews" element={token ? <ReviewsPage /> : <Navigate to="/login" replace />} />
<Route path="/analytics" element={token ? <AnalyticsPage /> : <Navigate to="/login" replace />} /> <Route path="/analytics" element={token ? <AnalyticsPage /> : <Navigate to="/login" replace />} />
<Route path="/delivery" element={token ? <DeliveryPage /> : <Navigate to="/login" replace />} />
<Route path="/pipeline" element={token ? <PipelinePage /> : <Navigate to="/login" replace />} />
<Route path="/cartable" element={token ? <CartablePage /> : <Navigate to="/login" replace />} /> <Route path="/cartable" element={token ? <CartablePage /> : <Navigate to="/login" replace />} />
<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 />} />
@@ -36,6 +43,7 @@ export default function App() {
<Route path="/agent-profiles" element={token ? <AgentProfilesPage /> : <Navigate to="/login" replace />} /> <Route path="/agent-profiles" element={token ? <AgentProfilesPage /> : <Navigate to="/login" replace />} />
<Route path="/product-profiles" element={token ? <ProductProfilesPage /> : <Navigate to="/login" replace />} /> <Route path="/product-profiles" element={token ? <ProductProfilesPage /> : <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="/help" element={token ? <KnowledgeBasePage /> : <Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
<Toaster richColors position="top-right" /> <Toaster richColors position="top-right" />
+105 -19
View File
@@ -1,7 +1,8 @@
import type { ReactNode } from 'react' import { useEffect, useState, type ReactNode } from 'react'
import { Link, useLocation } from 'react-router' import { Link, useLocation } from 'react-router'
import { import {
BookMarked, BookMarked,
BookOpen,
BookUser, BookUser,
Bot, Bot,
Boxes, Boxes,
@@ -13,27 +14,76 @@ import {
LogOut, LogOut,
Network, Network,
Package, Package,
Rocket,
ShieldCheck, ShieldCheck,
Sparkles, Sparkles,
TrendingUp,
Users, Users,
Workflow,
} from 'lucide-react' } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { api } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAuth } from '@/store/auth' import { useAuth } from '@/store/auth'
/** Event other pages fire after dispatching a run or deciding a review, so the badge updates at once. */
export const REVIEWS_CHANGED = 'teamup:reviews-changed'
/** Tracks the count of held actions awaiting review: polls, and refetches on focus / a change event. */
function usePendingReviewCount(organizationId: string | null): number {
const [count, setCount] = useState(0)
useEffect(() => {
if (!organizationId) {
setCount(0)
return
}
let cancelled = false
const tick = async () => {
try {
const items = await api.get<unknown[]>(`/api/governance/reviews?organizationId=${organizationId}&status=Pending`)
if (!cancelled) setCount(items.length)
} catch {
// leave the last known count on a transient failure
}
}
void tick()
const id = setInterval(tick, 6000)
const refetch = () => void tick()
window.addEventListener(REVIEWS_CHANGED, refetch)
window.addEventListener('focus', refetch)
document.addEventListener('visibilitychange', refetch)
return () => {
cancelled = true
clearInterval(id)
window.removeEventListener(REVIEWS_CHANGED, refetch)
window.removeEventListener('focus', refetch)
document.removeEventListener('visibilitychange', refetch)
}
}, [organizationId])
return count
}
export function AppShell({ children }: { children: ReactNode }) { export function AppShell({ children }: { children: ReactNode }) {
const email = useAuth((s) => s.email) const email = useAuth((s) => s.email)
const logout = useAuth((s) => s.logout) const logout = useAuth((s) => s.logout)
const organizationId = useAuth((s) => s.organizationId)
const reviewCount = usePendingReviewCount(organizationId)
return ( return (
<div className="flex min-h-screen text-foreground"> <div className="flex min-h-screen text-foreground">
<aside <aside
className="flex w-60 shrink-0 flex-col border-r border-white/15 text-sidebar-foreground backdrop-blur-2xl" className="flex w-60 shrink-0 flex-col border-r border-white/15 text-sidebar-foreground backdrop-blur-2xl"
style={{ background: 'linear-gradient(180deg, oklch(0.27 0.1 287 / 0.78) 0%, oklch(0.2 0.085 298 / 0.78) 100%)' }} style={{
background:
'linear-gradient(168deg, oklch(0.32 0.14 268 / 0.82) 0%, oklch(0.28 0.15 292 / 0.82) 42%, oklch(0.26 0.15 318 / 0.82) 72%, oklch(0.24 0.13 340 / 0.82) 100%)',
}}
> >
<div className="flex items-center gap-3 px-5 py-4"> <div className="flex items-center gap-3 px-5 py-4">
<span className="grid size-8 place-items-center rounded-md bg-sidebar-primary font-bold text-sidebar-primary-foreground"> <span
className="grid size-8 place-items-center rounded-md font-bold text-white shadow-sm"
style={{ background: 'linear-gradient(135deg, #6366f1, #d946ef)' }}
>
T T
</span> </span>
<div className="leading-tight"> <div className="leading-tight">
@@ -44,20 +94,34 @@ export function AppShell({ children }: { children: ReactNode }) {
<Separator className="bg-sidebar-border" /> <Separator className="bg-sidebar-border" />
<nav className="flex flex-1 flex-col gap-1 p-3"> <nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-3">
<NavItem icon={LayoutDashboard} label="Board" to="/" /> <NavItem icon={Rocket} label="Get started" to="/start" color="#fbbf24" />
<NavItem icon={Sparkles} label="Team" to="/team" />
<NavItem icon={Inbox} label="Cartable" to="/cartable" /> <NavSection label="Work" />
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" /> <NavItem icon={LayoutDashboard} label="Board" to="/" color="#38bdf8" />
<NavItem icon={Bot} label="AI seats" to="/seats" /> <NavItem icon={Sparkles} label="Team" to="/team" color="#38bdf8" />
<NavItem icon={BookUser} label="Agent profiles" to="/agent-profiles" /> <NavItem icon={Inbox} label="Cartable" to="/cartable" color="#38bdf8" />
<NavItem icon={BookMarked} label="Skills" to="/skills" /> <NavItem icon={Workflow} label="Delivery pipeline" to="/pipeline" color="#38bdf8" />
<NavItem icon={Package} label="Product profiles" to="/product-profiles" /> <NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" color="#38bdf8" badge={reviewCount} />
<NavItem icon={Network} label="Org chart" to="/org" />
<NavItem icon={Boxes} label="Structure" to="/structure" /> <NavSection label="Organization" />
<NavItem icon={Users} label="Members" to="/members" /> <NavItem icon={Boxes} label="Structure" to="/structure" color="#34d399" />
<NavItem icon={Gauge} label="Performance" to="/performance" /> <NavItem icon={Network} label="Org chart" to="/org" color="#34d399" />
<NavItem icon={ChartColumn} label="Analytics" to="/analytics" /> <NavItem icon={Users} label="Members" to="/members" color="#34d399" />
<NavSection label="AI & libraries" />
<NavItem icon={Bot} label="AI seats" to="/seats" color="#a78bfa" />
<NavItem icon={BookMarked} label="Skills" to="/skills" color="#a78bfa" />
<NavItem icon={BookUser} label="Agent profiles" to="/agent-profiles" color="#a78bfa" />
<NavItem icon={Package} label="Product profiles" to="/product-profiles" color="#a78bfa" />
<NavSection label="Insights" />
<NavItem icon={TrendingUp} label="Delivery" to="/delivery" color="#2dd4bf" />
<NavItem icon={Gauge} label="Performance" to="/performance" color="#2dd4bf" />
<NavItem icon={ChartColumn} label="Analytics" to="/analytics" color="#2dd4bf" />
<NavSection label="Help" />
<NavItem icon={BookOpen} label="Knowledge base" to="/help" color="#f472b6" />
</nav> </nav>
<Separator className="bg-sidebar-border" /> <Separator className="bg-sidebar-border" />
@@ -81,22 +145,34 @@ export function AppShell({ children }: { children: ReactNode }) {
) )
} }
function NavSection({ label }: { label: string }) {
return (
<div className="px-3 pb-1 pt-4 text-[10px] font-semibold uppercase tracking-wider text-sidebar-foreground/45">
{label}
</div>
)
}
function NavItem({ function NavItem({
icon: Icon, icon: Icon,
label, label,
to, to,
muted, muted,
color,
badge,
}: { }: {
icon: LucideIcon icon: LucideIcon
label: string label: string
to?: string to?: string
muted?: boolean muted?: boolean
color?: string
badge?: number
}) { }) {
const location = useLocation() const location = useLocation()
const active = to ? location.pathname === to : false const active = to ? location.pathname === to : false
const className = cn( const className = cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors', 'group/nav flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
active active
? 'bg-white/15 font-medium text-white shadow-sm ring-1 ring-white/15 backdrop-blur-sm' ? 'bg-white/15 font-medium text-white shadow-sm ring-1 ring-white/15 backdrop-blur-sm'
: 'text-sidebar-foreground/80', : 'text-sidebar-foreground/80',
@@ -105,8 +181,18 @@ function NavItem({
const content = ( const content = (
<> <>
<Icon className="size-4" /> <span
className="grid size-6 shrink-0 place-items-center rounded-md transition-colors"
style={{ backgroundColor: color ? `${color}26` : undefined, color }}
>
<Icon className="size-3.5" />
</span>
{label} {label}
{!!badge && badge > 0 && (
<span className="ml-auto grid h-5 min-w-5 place-items-center rounded-full bg-amber-400 px-1.5 text-[11px] font-semibold text-amber-950">
{badge}
</span>
)}
{muted && <span className="ml-auto text-[10px] uppercase tracking-wide opacity-70">soon</span>} {muted && <span className="ml-auto text-[10px] uppercase tracking-wide opacity-70">soon</span>}
</> </>
) )
+50
View File
@@ -0,0 +1,50 @@
import { useMemo } from 'react'
/**
* Pull the most likely runnable code out of an artifact: the largest fenced code block if the agent
* wrapped its answer in markdown, otherwise the whole text.
*/
export function extractCode(artifact: string): string {
const blocks = [...artifact.matchAll(/```(?:tsx|jsx|ts|js|javascript|typescript)?\s*\n([\s\S]*?)```/g)].map((m) => m[1])
if (blocks.length === 0) return artifact.trim()
return blocks.sort((a, b) => b.length - a.length)[0].trim()
}
/** Builds a self-contained HTML document that transpiles the component (Babel) and renders it with
* React + Tailwind from CDN. The agent's code must define a component named `App`. */
function harness(code: string): string {
const json = JSON.stringify(code)
return `<!doctype html><html><head><meta charset="utf-8"/>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
<script type="importmap">{"imports":{"react":"https://esm.sh/react@18.3.1","react-dom":"https://esm.sh/react-dom@18.3.1","react-dom/client":"https://esm.sh/react-dom@18.3.1/client","react/jsx-runtime":"https://esm.sh/react@18.3.1/jsx-runtime","react/jsx-dev-runtime":"https://esm.sh/react@18.3.1/jsx-dev-runtime"}}</script>
<style>body{margin:0;font-family:system-ui,sans-serif}.__err{padding:18px;font:12px/1.6 ui-monospace,monospace;color:#b91c1c;white-space:pre-wrap}</style>
</head><body>
<div id="root"></div>
<script>
var CODE = ${json};
function esc(s){return String(s).replace(/[<&]/g,function(c){return c==='<'?'&lt;':'&amp;'})}
function showErr(m){var r=document.getElementById('root');if(r)r.innerHTML='<div class="__err">Preview error:\\n'+esc(m)+'</div>'}
window.addEventListener('error',function(e){showErr(e.message||e)});
window.addEventListener('unhandledrejection',function(e){showErr((e.reason&&e.reason.message)||e.reason||'Error')});
try{
var t = Babel.transform(CODE,{presets:[['react',{runtime:'automatic'}],'typescript'],filename:'App.tsx'}).code;
var boot = t + "\\nimport React from 'react';\\nimport { createRoot } from 'react-dom/client';\\nvar __C = (typeof App!=='undefined') ? App : null;\\nif(!__C){ throw new Error('No component named App. Output a component, e.g. export default function App(){ return <div/> }'); }\\ncreateRoot(document.getElementById('root')).render(React.createElement(__C));";
var s=document.createElement('script');s.type='module';s.textContent=boot;document.body.appendChild(s);
}catch(e){showErr(e.message)}
</script>
</body></html>`
}
/** Runs an agent's React component artifact live, in a sandboxed iframe. */
export function LivePreview({ artifact, className }: { artifact: string; className?: string }) {
const srcDoc = useMemo(() => harness(extractCode(artifact)), [artifact])
return (
<iframe
title="Live preview"
srcDoc={srcDoc}
sandbox="allow-scripts allow-same-origin"
className={className ?? 'h-[70vh] w-full rounded-lg border bg-white'}
/>
)
}
+196
View File
@@ -0,0 +1,196 @@
import { useEffect, useRef, useState } from 'react'
import { AlertTriangle, BrainCircuit, Check, Inbox, Loader2, Wrench } from 'lucide-react'
import { api } from '@/lib/api'
/** The live run record as returned by GET /api/assembler/runs/{id}. */
interface Run {
id: string
status: 'Queued' | 'Running' | 'Completed' | 'Failed' | string
actionType: string | null
actionRisk: string | null
output: string | null
error: string | null
resultJson: string | null
latencyMs: number | null
createdAtUtc: string
completedAtUtc: string | null
}
interface ToolCall {
name?: string
tool?: string
server?: string
ok?: boolean
}
const TERMINAL = new Set(['Completed', 'Failed'])
/** Pulls the tool-call list out of the run's result JSON, if the agent used any MCP tools. */
function readToolCalls(run: Run | null): ToolCall[] {
if (!run?.resultJson) return []
try {
const parsed = JSON.parse(run.resultJson) as { toolCalls?: ToolCall[] }
return Array.isArray(parsed.toolCalls) ? parsed.toolCalls : []
} catch {
return []
}
}
/**
* Watches one agent run live: polls the run until it reaches a terminal state and renders a
* step-by-step timeline (Queued → Thinking → Done) with elapsed time, the action the agent took,
* its risk tag, any tool calls, and where the result landed.
*/
export function RunProgress({
runId,
onSettled,
}: {
runId: string
/** Fired once when the run reaches Completed/Failed — lets the parent refresh the board + badge. */
onSettled?: (run: Run) => void
}) {
const [run, setRun] = useState<Run | null>(null)
const [elapsed, setElapsed] = useState(0)
const settledRef = useRef(false)
const onSettledRef = useRef(onSettled)
onSettledRef.current = onSettled
useEffect(() => {
let active = true
let pollTimer = 0
const start = Date.now()
const tick = window.setInterval(() => {
if (active && !settledRef.current) setElapsed(Math.round((Date.now() - start) / 1000))
}, 1000)
const poll = async () => {
try {
const r = await api.get<Run>(`/api/assembler/runs/${runId}`)
if (!active) return
setRun(r)
if (TERMINAL.has(r.status)) {
settledRef.current = true
onSettledRef.current?.(r)
return
}
} catch {
/* transient — keep polling */
}
if (active) pollTimer = window.setTimeout(poll, 1200)
}
void poll()
return () => {
active = false
window.clearTimeout(pollTimer)
window.clearInterval(tick)
}
}, [runId])
const status = run?.status ?? 'Queued'
const failed = status === 'Failed'
const done = status === 'Completed'
const running = status === 'Running'
const tools = readToolCalls(run)
const seconds = run?.latencyMs != null ? (run.latencyMs / 1000).toFixed(1) : elapsed.toString()
return (
<div className="flex flex-col gap-3 rounded-xl border bg-card/60 p-4 backdrop-blur-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">Agent at work</span>
<span className="text-xs tabular-nums text-muted-foreground">{seconds}s</span>
</div>
<ol className="flex flex-col gap-2.5">
<Step
state={status === 'Queued' ? 'active' : 'done'}
icon={Inbox}
title="Queued"
detail="Task accepted — waiting for a worker."
/>
<Step
state={failed && !done ? (run?.output ? 'done' : 'error') : running ? 'active' : status === 'Queued' ? 'pending' : 'done'}
icon={BrainCircuit}
title="Thinking"
detail="Assembling the prompt and calling the model."
/>
{tools.length > 0 && (
<Step
state="done"
icon={Wrench}
title={`Used ${tools.length} tool${tools.length === 1 ? '' : 's'}`}
detail={tools.map((t) => t.name ?? t.tool).filter(Boolean).join(', ')}
/>
)}
<Step
state={failed ? 'error' : done ? 'done' : 'pending'}
icon={done ? Check : AlertTriangle}
title={failed ? 'Failed' : 'Delivered'}
detail={
failed
? run?.error ?? 'The run failed.'
: done
? `${run?.actionType ?? 'Result'} produced — sent to the review inbox.`
: 'Result will land in the review inbox.'
}
/>
</ol>
{done && run?.actionType && (
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
<span className="rounded-md bg-muted px-2 py-0.5 text-xs font-medium">{run.actionType}</span>
{run.actionRisk && (
<span className="rounded-md bg-amber-500/15 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-400">
{run.actionRisk} risk
</span>
)}
</div>
)}
{done && run?.output && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">Show output</summary>
<div className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-lg bg-muted p-3 leading-relaxed">
{run.output}
</div>
</details>
)}
</div>
)
}
type StepState = 'pending' | 'active' | 'done' | 'error'
function Step({
state,
icon: Icon,
title,
detail,
}: {
state: StepState
icon: typeof Check
title: string
detail?: string
}) {
const ring =
state === 'done'
? 'border-approved bg-approved/15 text-approved'
: state === 'active'
? 'border-primary bg-primary/15 text-primary'
: state === 'error'
? 'border-destructive bg-destructive/15 text-destructive'
: 'border-border bg-muted/40 text-muted-foreground'
return (
<li className="flex items-start gap-3">
<span className={`mt-0.5 grid size-7 shrink-0 place-items-center rounded-full border ${ring}`}>
{state === 'active' ? <Loader2 className="size-3.5 animate-spin" /> : <Icon className="size-3.5" />}
</span>
<div className="min-w-0">
<p className={`text-sm font-medium ${state === 'pending' ? 'text-muted-foreground' : ''}`}>{title}</p>
{detail && <p className="text-xs text-muted-foreground">{detail}</p>}
</div>
</li>
)
}
+12 -1
View File
@@ -21,7 +21,18 @@ async function request<T>(method: string, url: string, body?: unknown): Promise<
} }
const contentType = response.headers.get('content-type') ?? '' const contentType = response.headers.get('content-type') ?? ''
return contentType.includes('application/json') ? ((await response.json()) as T) : (undefined as T) if (contentType.includes('application/json')) {
return (await response.json()) as T
}
// A non-JSON 2xx on an /api call is almost always the SPA fallback (index.html) — i.e. the route
// doesn't exist on the backend that's running (a stale build). Fail loudly so callers surface it,
// instead of returning undefined and letting the page crash on `.map`/`.length`.
if (url.startsWith('/api') && contentType.includes('text/html')) {
throw new Error(`Unexpected HTML from ${url}. The API is likely running an older build — restart the server.`)
}
return undefined as T
} }
export const api = { export const api = {
+312
View File
@@ -0,0 +1,312 @@
export interface KbArticle {
id: string
category: string
title: string
summary: string
keywords: string
body: string
}
export const KB_CATEGORIES = [
'Getting started',
'Concepts',
'Build your team',
'Run & review',
'Libraries',
'Insights & governance',
'Troubleshooting',
] as const
// Authored as Markdown. Examples use 4-space indented code blocks (no backticks) so they render
// cleanly. Keep articles practical: what it is, steps, and an example.
export const KB_ARTICLES: KbArticle[] = [
{
id: 'what-is-teamup',
category: 'Concepts',
title: 'What is TeamUp?',
summary: 'A live org chart that does work — humans and governed AI agents on one board.',
keywords: 'overview intro what is teamup agents',
body: `## What is TeamUp?
TeamUp.AI is a **live org chart that does work**. You model your organization — divisions, products, teams, and the role-seats inside them — and any open seat can be filled by a **governed AI agent** that does that role's work: writing specs, breaking down stories, implementing, drafting test plans, reviewing.
Humans and AI work side by side on one board, and **every AI action is a proposal a human reviews** before it takes effect.
### Why it's different
- It has a concept of a **team** and of **role coverage** — not just a single assistant in one editor.
- Output is **governed**: an action waits in a review queue unless you allow it to run.
- It measures **human edit distance** — how little you change an agent's output before approving. Low and falling means the AI is trusted.
### The loop in one line
Model the org → give the product an identity → staff seats with agents → assign work → review the action, result, and log → approve → the result lands, memory compounds, and the next role picks up automatically.`,
},
{
id: 'orientation',
category: 'Concepts',
title: '5-minute orientation: the key terms',
summary: 'Seats, agents, skills, autonomy, the action gate, memory, and the metric.',
keywords: 'concepts terms glossary seat agent skill autonomy gate memory',
body: `## The terms you'll see everywhere
- **Organization → Division → Product → Team → Seat** — your structure. A *team* runs a board; a *seat* is a role on a team.
- **Seat states (the triad)** — *Human* (slate), *Open* (amber), or *AI* (indigo). An open seat can be staffed by an agent.
- **Agent** — the AI in a seat: a name + persona, matched **skills**, an **autonomy** level, a model connection, and optional docs/tools.
- **Skill** — a reusable capability (a SKILL.md) such as *spec-writing* or *test-plan-generation*. Gated by golden tests.
- **Product identity (PRODUCT.md)** — a brief shared by every agent on the product, injected into every run.
- **Autonomy dial** — *Draft-only / Gated / Autonomous*: whether an action waits for a human or runs immediately.
- **Action & risk** — every output is an action with a risk tag (*read / draft / publish / destructive*).
- **Action gate** — compares autonomy to risk; holds the action for review or executes it. **Destructive always waits for a human.**
- **Review inbox** — where held actions wait. You see the action, the result, and the run log, then approve / edit / send back.
- **Working memory** — decisions you approve become recallable team and product memory, read at the next run.
- **Human edit distance** — the north-star metric: how much you change agent output before approving.`,
},
{
id: 'quick-start',
category: 'Getting started',
title: 'Quick start: zero to your first AI agent',
summary: 'The whole A-to-Z in seven steps. The Get started page tracks your progress.',
keywords: 'quick start setup onboarding first agent a to z',
body: `## From zero to a working AI team
The **Get started** page (top of the sidebar) tracks these steps and shows what's done.
1. **Sign in** — or, first time, choose *Bootstrap the owner* to create your org.
2. **Model the org** (Structure) — add a product and at least one team under it.
3. **Give the product an identity** (Structure → Identity) — write a short PRODUCT.md brief.
4. **Connect a model** (AI seats → Model connections) — add your API key (BYOK).
5. **Staff a seat** (AI seats) — pick a role, choose skills + autonomy + model, save. The seat turns AI.
6. **Fill the backlog** (Board) — create tasks and assign them to agents or humans.
7. **Review the first output** (Review inbox) — the agent's proposal waits for your approval.
### Example
Create product **HRM**, team **HRM Engineering**, staff a **Backend Engineer** seat with an agent named *Dex* (skills: code-implementation, bug-diagnosis; autonomy: Gated). Create a story, assign it to Dex, and watch it run — its output lands in your review inbox.`,
},
{
id: 'model-org',
category: 'Build your team',
title: 'Model your org: divisions, products, teams',
summary: 'Build the spine: Organization → Divisions → Products/Services → Teams.',
keywords: 'structure org division product team build spine',
body: `## Model the organization
Go to **Structure**.
1. *(Optional)* Add **Divisions** — top-level slices (Technical, Finance, HR…).
2. Add a **Product** or **Service** — engineering divisions ship products; others run services. Example: *HRM*.
3. Add **Teams** and attach each to a product. A team is the unit that runs a board. Example: *HRM Product*, *HRM Engineering*, *HRM Quality*.
### Why product matters
A team should sit **under a product** — that's what lets the product's shared identity and memory reach the team's agents. Teams with no product still work, but they don't get product-level context.`,
},
{
id: 'product-identity',
category: 'Build your team',
title: 'Write a product identity (PRODUCT.md)',
summary: 'A brief shared by every agent on the product, injected into every run.',
keywords: 'product identity product.md brief shared context',
body: `## Give the product an identity
On **Structure**, click **Identity** on a product. Write a PRODUCT.md — frontmatter + a Markdown brief — using the Edit / Preview tabs, then **Save**. It is now injected into every agent run on the product.
### What to include
- What the product is and who it serves.
- Its modules / scope.
- Conventions every contributor must follow.
### Example PRODUCT.md
---
product: HRM
version: 1.0.0
summary: A modular HRM application for mid-sized companies.
---
# HRM — Human Resource Management
Employees, attendance, leave, payroll, recruitment, performance.
## Conventions
- Money in minor units (integers); currency per company.
- Dates/times stored in UTC; shown in the company timezone.
- PII is access-controlled by role; least privilege.
**Tip:** keep reusable templates in **Product profiles** and apply one to a product in a click.`,
},
{
id: 'byok',
category: 'Build your team',
title: 'Connect a model (BYOK)',
summary: 'Add your own API key — owner-only, encrypted, never returned.',
keywords: 'byok model api key connection openai endpoint',
body: `## Connect a model
Go to **AI seats → Model connections**.
1. Click **Add**. Enter a **name**, **provider** (OpenAI-compatible), **model**, and your **API key**.
2. *(Optional)* Set a **Base URL** for a gateway. You can paste a base URL *or* the full chat-completions URL — both work.
3. Save, then click **Test** to confirm a call succeeds.
### Security
Keys are **owner-only**, **encrypted at rest**, used **server-side only**, and **never returned** to a client after saving. Team owners assign a connection from a list; they never see the key.`,
},
{
id: 'staff-seat',
category: 'Build your team',
title: 'Staff a seat with an AI agent',
summary: 'Turn an open role-seat into a governed AI teammate.',
keywords: 'staff seat agent configure autonomy skills persona',
body: `## Staff a seat
Go to **AI seats**, pick a team, and select a role-seat (e.g. *Product Owner*).
1. *(Optional)* **Start from a profile** (AGENTS.md) to prefill identity, skills, and persona.
2. Give the agent a **name** and **monogram**.
3. Set **autonomy** — start with **Gated** so output waits for review.
4. Pick **skills** — the configurator suggests a set for the role.
5. Choose the **model connection**. *(Optional)* attach **docs** and **MCP tool servers**.
6. **Save** — the seat turns AI and the agent appears with a live animated face.
### Example
*Ava*, Product Owner — skills: spec-writing, story-breakdown, requirements-analysis; autonomy: Gated; model: your connection. Ava now drafts specs and child stories for any task you assign her, holding them in review.`,
},
{
id: 'board-assign',
category: 'Run & review',
title: 'Create and assign work on the board',
summary: 'Tasks across Backlog → In progress → In review → Done, assigned to humans or AI.',
keywords: 'board task assign columns story spec drag',
body: `## Run delivery on the board
Go to **Board** and pick a team.
1. **Create a task** — give it a title and type (Spec, Story, Test, Review).
2. **Assign** it to a human or to an **AI agent** (open the task to assign).
3. **Move** tasks across the columns: Backlog → In progress → In review → Done.
Assigning a task to an AI seat **dispatches a run**: the work is queued and executed off the request path, and the agent's face animates *thinking → working*. A Gated agent's output then waits in the **Review inbox**.`,
},
{
id: 'review-inbox',
category: 'Run & review',
title: 'Read the review: action, result, and run log',
summary: 'Every held item shows what the AI will do, what it produced, and how it got there.',
keywords: 'review inbox action result run log trace approve transparency',
body: `## The review inbox in depth
Every held action is shown in three parts so you can decide with full context.
1. **Action** — a plain statement of what approving does (e.g. "write this artifact to the board and create N child tasks"), with a warning for destructive actions.
2. **Result** — the proposed **artifact** (editable) and the proposed **child tasks**. As you edit, a live **diff** highlights exactly what you changed.
3. **Run log** — expand it to see *how the agent got there*:
- latency, the agent and its autonomy;
- which **skills** were applied;
- which **tools** were available and **actually called** (with success/failure);
- how many **memory hits** were recalled and whether the **product identity** was included;
- the **raw model output** and the full **assembled prompt**.
### Example
Ava's "Employee management" spec shows: *write-spec · Draft*, the drafted spec, and a run log reading "21.3s · skills: spec-writing, story-breakdown · product identity included". You read exactly what she did before approving.`,
},
{
id: 'approve-edit-sendback',
category: 'Run & review',
title: 'Approve, edit & approve, or send back',
summary: 'Your three choices — and the one that feeds the metric.',
keywords: 'approve edit send back decision edit distance metric',
body: `## Your three decisions
- **Approve** — the result executes: the artifact lands on the board and any child tasks are created. Recorded with zero edit distance.
- **Edit & approve** — your edited version executes, and the **edit distance** (how much you changed) is recorded. *This is the metric.*
- **Send back** — nothing executes; the item returns to the agent to try again.
### Why edit-and-approve matters
The whole bet is that you **edit a little** rather than rewrite from scratch. Edit-and-approve is what feeds human edit distance — the signal that proves an agent is trustworthy. Watch it fall over a sprint in **Analytics**.`,
},
{
id: 'handoff-memory',
category: 'Run & review',
title: 'The PO→QA handoff and working memory',
summary: 'Done stories hand off to QA automatically; approvals become shared memory.',
keywords: 'handoff qa done memory correction product team shared',
body: `## Automatic handoff
When a story is marked **Done** on a team, TeamUp automatically creates a **QA task** for the team's QA agent, with provenance back to the story. The QA agent drafts a test plan that waits in review.
Guardrails prevent loops: QA output never re-triggers QA, and a task gets at most one handoff.
## Working memory
Every decision you approve — and especially every **correction** — is written to **working memory** and read back, by relevance, at the next run.
Memory is **layered**: a product's decisions are shared by **every agent across the product's teams**, while team memory keeps local context. So your corrections compound into institutional knowledge instead of being repeated.
### Example
You correct Ava's spec to say "money in minor units". Next time *any* HRM agent runs, that correction is recalled — Dex the backend engineer sees it too.`,
},
{
id: 'libraries',
category: 'Libraries',
title: 'Skills, agent profiles & product profiles',
summary: 'Reusable, versioned building blocks — author, version, publish, install, apply.',
keywords: 'skills agent profiles product profiles library marketplace version fork publish',
body: `## Three reusable libraries
- **Skills** (SKILL.md) — capabilities an agent runs (spec-writing, code-implementation…). Free builtins ship for everyone; author your own, version them, and publish to the marketplace. A skill must be **Published** (a role + a passing golden test) to run.
- **Agent profiles** (AGENTS.md) — reusable agent definitions (identity + skills + autonomy + persona). Apply one to a seat to prefill it.
- **Product profiles** (PRODUCT.md) — reusable product identities. **Apply** one to a product to set its shared identity.
### Common actions (all three libraries)
1. **View** — read the full .md (Edit / Preview).
2. **Edit** / **New version** — change your own, or bump the version.
3. **Fork** — copy a builtin into your org to customize.
4. **Publish / Unpublish** — list or unlist on the marketplace.
5. **Install** — copy a marketplace item into your library.`,
},
{
id: 'analytics',
category: 'Insights & governance',
title: 'Analytics & the metric',
summary: 'Approval rate, tasks done, and human edit distance per agent.',
keywords: 'analytics metric edit distance approval rate performance trend',
body: `## Proving the bet
The **Analytics** view tracks the numbers that matter:
- **Approval rate** and **tasks done**;
- **Human edit distance** per agent, with a trend line.
If edit distance is **low and falling** across a sprint, your AI teammates are trusted and saving you more time than they cost to supervise. The **Performance** view puts humans and AI on the same accountability scale.`,
},
{
id: 'governance',
category: 'Insights & governance',
title: 'Governance & safety — what TeamUp guarantees',
summary: 'Permission checks, the action gate, BYOK, and data-not-instructions.',
keywords: 'governance safety security permission gate destructive byok air-gap',
body: `## What TeamUp guarantees
- **Permission on every mutation**, at the right scope. The UI is never trusted for authorization.
- **AI output is a proposal.** Nothing takes effect until the action gate allows it — and **destructive actions always wait for a human**, whatever the autonomy.
- **BYOK keys** are owner-only, encrypted at rest, used server-side only, never returned to a client.
- **Retrieved content** (docs, code, tool output) is treated as **data, not instructions**.
- **Skills are golden-tested** — only passing skills can run or be published.
- **Self-hostable and air-gappable** as a single unit.`,
},
{
id: 'troubleshooting',
category: 'Troubleshooting',
title: 'Troubleshooting & tips',
summary: 'Identity not injecting, skills not running, model test failing, and more.',
keywords: 'troubleshooting tips problems identity skill draft model test fails',
body: `## Common issues
- **Agent identity doesn't inject?** A team must be **under a product**, and that product must have a **PRODUCT.md identity**, for the shared identity to appear in runs.
- **A skill won't run?** It must be **Published** — give it at least one role and a passing golden test.
- **Model test fails?** Check the provider, model name, key, and endpoint. A full *chat-completions* URL works as well as a base URL.
- **Nothing in the review inbox?** Assign a task to a **Gated** AI seat — Autonomous agents execute low-risk actions directly without holding.
## Tips
- **Start agents Gated**, then raise autonomy once you trust them.
- **Edit-and-approve** (don't rewrite) — that's what feeds the metric.
- **Mark a story Done** to fire the PO→QA handoff.`,
},
]
+108 -11
View File
@@ -8,9 +8,11 @@ import {
useSensors, useSensors,
type DragEndEvent, type DragEndEvent,
} from '@dnd-kit/core' } from '@dnd-kit/core'
import { Bot, Plus } from 'lucide-react' import { Bot, Play, Plus, Sparkles, Trash2 } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell' import { AppShell, REVIEWS_CHANGED } from '@/components/AppShell'
import { LivePreview } from '@/components/LivePreview'
import { RunProgress } from '@/components/RunProgress'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@@ -73,7 +75,8 @@ export function BoardPage() {
const [orgName, setOrgName] = useState('') const [orgName, setOrgName] = useState('')
const [teams, setTeams] = useState<Team[]>([]) const [teams, setTeams] = useState<Team[]>([])
const [teamId, setTeamId] = useState<string | null>(null) // Remember the selected team across refreshes (it used to snap back to the first team).
const [teamId, setTeamId] = useState<string | null>(() => localStorage.getItem('teamup.board.team'))
const [board, setBoard] = useState<Board | null>(null) const [board, setBoard] = useState<Board | null>(null)
const [newTeam, setNewTeam] = useState('') const [newTeam, setNewTeam] = useState('')
const [newTask, setNewTask] = useState('') const [newTask, setNewTask] = useState('')
@@ -90,7 +93,10 @@ export function BoardPage() {
try { try {
const result = await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`) const result = await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`)
setTeams(result) setTeams(result)
setTeamId((current) => current ?? result[0]?.id ?? null) // Keep the current/remembered team if it still exists; otherwise fall back to the first.
setTeamId((current) =>
current && result.some((team) => team.id === current) ? current : result[0]?.id ?? null,
)
} catch (err) { } catch (err) {
toast.error((err as Error).message) toast.error((err as Error).message)
} }
@@ -112,6 +118,11 @@ export function BoardPage() {
if (teamId) void loadBoard(teamId) if (teamId) void loadBoard(teamId)
}, [teamId, loadBoard]) }, [teamId, loadBoard])
// Persist the selected team so a refresh stays on it.
useEffect(() => {
if (teamId) localStorage.setItem('teamup.board.team', teamId)
}, [teamId])
async function run(action: () => Promise<unknown>) { async function run(action: () => Promise<unknown>) {
try { try {
await action() await action()
@@ -396,8 +407,16 @@ function TaskDrawer({
}) { }) {
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [seatId, setSeatId] = useState<string>('') const [seatId, setSeatId] = useState<string>('')
const [preview, setPreview] = useState(false)
const [runId, setRunId] = useState<string | null>(null)
const aiSeats = seats.filter((s) => s.state === 'Ai') const aiSeats = seats.filter((s) => s.state === 'Ai')
// Switching to a different task clears the live run panel so it never shows a stale run.
const taskId = task?.id
useEffect(() => {
setRunId(null)
}, [taskId])
if (!task) { if (!task) {
return null return null
} }
@@ -502,17 +521,38 @@ function TaskDrawer({
</Select> </Select>
<Button <Button
disabled={busy || !seatId} disabled={busy || !seatId}
onClick={() => onClick={() => {
act( let started: string | null = null
() => api.post('/api/assembler/runs', { seatId, workItemId: task.id }), act(async () => {
'Dispatched — the proposal will land in the review inbox.', const run = await api.post<{ id: string }>('/api/assembler/runs', { seatId, workItemId: task.id })
) started = run?.id ?? null
} // Visible feedback: the task leaves Backlog for In Progress while the agent works.
if (task.status === 'Backlog') {
await api.patch(`/api/orgboard/tasks/${task.id}/move`, { status: 'InProgress' })
}
}, 'Agent started — watch it work below.').then(() => {
if (started) setRunId(started)
window.dispatchEvent(new Event(REVIEWS_CHANGED))
})
}}
> >
<Bot data-icon="inline-start" /> <Bot data-icon="inline-start" />
Run Run
</Button> </Button>
</div> </div>
{/* Live process: watch the agent move through Queued → Thinking → Delivered in real time. */}
{runId && (
<RunProgress
key={runId}
runId={runId}
onSettled={() => {
// Result is in the review inbox and the board has moved — refresh both.
window.dispatchEvent(new Event(REVIEWS_CHANGED))
onChanged()
}}
/>
)}
</div> </div>
)} )}
@@ -529,7 +569,30 @@ function TaskDrawer({
{children.length > 0 && ( {children.length > 0 && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Child tasks</Label> <div className="flex items-center justify-between">
<Label>Child tasks</Label>
{aiSeats.length > 0 && (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() =>
act(async () => {
const res = await api.post<{ dispatched: number }>(
`/api/orgboard/tasks/${task.id}/run-all`,
seatId ? { seatId } : {},
)
if (res.dispatched === 0) throw new Error('Nothing left to run — all children are done or delivered.')
}, 'Autopilot started — running every outstanding child on the AI seats.').then(() => {
window.dispatchEvent(new Event(REVIEWS_CHANGED))
setTimeout(() => window.dispatchEvent(new Event(REVIEWS_CHANGED)), 5000)
})
}
>
<Sparkles data-icon="inline-start" /> Run all with AI
</Button>
)}
</div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{children.map((child) => ( {children.map((child) => (
<button <button
@@ -545,7 +608,41 @@ function TaskDrawer({
</div> </div>
</div> </div>
)} )}
<div className="mt-2 flex items-center gap-2 border-t pt-4">
{task.description && (
<Button variant="outline" size="sm" onClick={() => setPreview(true)}>
<Play data-icon="inline-start" /> Preview
</Button>
)}
<Button
variant="destructive"
size="sm"
className="ml-auto"
disabled={busy}
onClick={() => {
if (window.confirm(`Delete "${task.title}"? This can't be undone.`)) {
act(() => api.del(`/api/orgboard/tasks/${task.id}`), 'Task deleted.').then(onClose)
}
}}
>
<Trash2 data-icon="inline-start" /> Delete task
</Button>
</div>
</SheetContent> </SheetContent>
{preview && task.description && (
<div className="fixed inset-0 z-[100] flex flex-col gap-2 bg-background/95 p-4 backdrop-blur-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Live preview · {task.title}</span>
<Button size="sm" variant="outline" onClick={() => setPreview(false)}>Close</Button>
</div>
<LivePreview artifact={task.description} className="w-full flex-1 rounded-lg border bg-white" />
<p className="text-center text-xs text-muted-foreground">
Runs the artifact as a React component (Babel + Tailwind, no build). Define a component named App.
</p>
</div>
)}
</Sheet> </Sheet>
) )
} }
+264
View File
@@ -0,0 +1,264 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Download, Gauge } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { api } from '@/lib/api'
import { useAuth } from '@/store/auth'
interface Product {
id: string
name: string
}
interface Team {
id: string
name: string
productId: string | null
}
interface Task {
id: string
title: string
status: string
type: string
}
interface Board {
columns: { status: string; items: Task[] }[]
}
interface Analytics {
approvalRate: number | null
avgEditDistance: number | null
tasksDone: number
pendingReviews: number
}
const COLUMNS = ['Backlog', 'InProgress', 'InReview', 'Done'] as const
const LABEL: Record<string, string> = { Backlog: 'Backlog', InProgress: 'In progress', InReview: 'In review', Done: 'Done' }
interface TeamProgress {
team: Team
counts: Record<string, number>
total: number
done: number
remaining: Task[]
}
export function DeliveryPage() {
const organizationId = useAuth((s) => s.organizationId)
const [products, setProducts] = useState<Product[]>([])
const [productId, setProductId] = useState<string | null>(() => localStorage.getItem('teamup.delivery.product'))
const [teams, setTeams] = useState<TeamProgress[]>([])
const [analytics, setAnalytics] = useState<Analytics | null>(null)
const [downloading, setDownloading] = useState(false)
useEffect(() => {
if (!organizationId) return
void (async () => {
try {
const [list, an] = await Promise.all([
api.get<Product[]>(`/api/orgboard/products?organizationId=${organizationId}`),
api.get<Analytics>(`/api/governance/analytics?organizationId=${organizationId}`).catch(() => null),
])
setProducts(list)
setAnalytics(an)
setProductId((cur) => (cur && list.some((p) => p.id === cur) ? cur : list[0]?.id ?? null))
} catch (err) {
toast.error((err as Error).message)
}
})()
}, [organizationId])
const loadProduct = useCallback(async (pid: string) => {
try {
const allTeams = await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`)
const productTeams = allTeams.filter((t) => t.productId === pid)
const progress = await Promise.all(
productTeams.map(async (team) => {
const board = await api.get<Board>(`/api/orgboard/board?teamId=${team.id}`).catch(() => ({ columns: [] }))
const counts: Record<string, number> = {}
let total = 0
const remaining: Task[] = []
for (const col of board.columns) {
counts[col.status] = col.items.length
total += col.items.length
if (col.status !== 'Done') remaining.push(...col.items)
}
return { team, counts, total, done: counts.Done ?? 0, remaining }
}),
)
setTeams(progress)
} catch (err) {
toast.error((err as Error).message)
}
}, [organizationId])
useEffect(() => {
if (productId) {
localStorage.setItem('teamup.delivery.product', productId)
void loadProduct(productId)
}
}, [productId, loadProduct])
const totals = useMemo(() => {
const counts: Record<string, number> = { Backlog: 0, InProgress: 0, InReview: 0, Done: 0 }
let total = 0
for (const t of teams) {
for (const c of COLUMNS) counts[c] += t.counts[c] ?? 0
total += t.total
}
const done = counts.Done
const pct = total > 0 ? Math.round((done / total) * 100) : 0
return { counts, total, done, pct, remaining: total - done }
}, [teams])
const product = products.find((p) => p.id === productId) ?? null
// Download the product as a zip of its delivered artifacts. The export endpoint streams a file, so we
// fetch it with the auth header (the api helper only does JSON), then trigger a browser download.
const downloadProject = useCallback(async () => {
if (!productId) return
setDownloading(true)
try {
const token = useAuth.getState().token
const res = await fetch(`/api/orgboard/products/${productId}/export`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error(`Export failed (${res.status})`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${(product?.name ?? 'project').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}.zip`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} catch (err) {
toast.error((err as Error).message)
} finally {
setDownloading(false)
}
}, [productId, product])
return (
<AppShell>
<div className="mx-auto max-w-4xl p-6">
<header className="mb-5 flex items-center justify-between gap-4">
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Gauge className="size-6" /> Delivery
</h1>
<div className="flex items-center gap-2">
{product && (
<Button variant="outline" size="sm" disabled={downloading} onClick={downloadProject}>
<Download data-icon="inline-start" />
{downloading ? 'Preparing…' : 'Download project'}
</Button>
)}
{products.length > 0 && (
<Select value={productId ?? ''} onValueChange={setProductId}>
<SelectTrigger className="w-56"><SelectValue placeholder="Pick a product" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{products.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
)}
</div>
</header>
{product && (
<>
{/* Progress hero */}
<div className="mb-5 rounded-xl border bg-card/60 p-5 backdrop-blur-sm">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">{product.name}</p>
<p className="text-3xl font-semibold">{totals.pct}% complete</p>
<p className="text-sm text-muted-foreground">
{totals.done} of {totals.total} tasks done · {totals.remaining} remaining
</p>
</div>
</div>
<div className="mt-4 h-3 w-full overflow-hidden rounded-full bg-muted/60">
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${totals.pct}%` }} />
</div>
</div>
{/* Column breakdown */}
<div className="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-4">
{COLUMNS.map((c) => (
<div key={c} className="rounded-lg border bg-card/50 p-3 backdrop-blur-sm">
<p className="text-xs text-muted-foreground">{LABEL[c]}</p>
<p className="text-2xl font-semibold">{totals.counts[c]}</p>
</div>
))}
</div>
{/* Quality (from analytics) */}
{analytics && (
<div className="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
<Stat label="Approval rate" value={analytics.approvalRate == null ? '—' : `${Math.round(analytics.approvalRate * 100)}%`} />
<Stat label="Avg edit distance" value={analytics.avgEditDistance == null ? '—' : analytics.avgEditDistance.toFixed(2)} />
<Stat label="Awaiting review" value={String(analytics.pendingReviews)} />
</div>
)}
{/* Per team */}
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Teams</h2>
<div className="mb-6 flex flex-col gap-2">
{teams.map((t) => {
const pct = t.total > 0 ? Math.round((t.done / t.total) * 100) : 0
return (
<div key={t.team.id} className="flex items-center gap-3 rounded-lg border bg-card/50 px-3 py-2 backdrop-blur-sm">
<span className="w-40 shrink-0 truncate text-sm font-medium">{t.team.name}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted/60">
<div className="h-full rounded-full bg-approved" style={{ width: `${pct}%` }} />
</div>
<span className="w-24 shrink-0 text-right text-xs text-muted-foreground">{t.done}/{t.total} · {pct}%</span>
</div>
)
})}
{teams.length === 0 && <p className="text-sm text-muted-foreground">No teams on this product yet.</p>}
</div>
{/* Remaining */}
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Remaining ({totals.remaining})
</h2>
<div className="flex flex-col gap-1.5">
{teams.flatMap((t) => t.remaining.map((task) => ({ task, team: t.team.name }))).slice(0, 40).map(({ task, team }) => (
<div key={task.id} className="flex items-center gap-2 rounded-md border bg-card/40 px-3 py-1.5 text-sm backdrop-blur-sm">
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">{LABEL[task.status] ?? task.status}</span>
<span className="min-w-0 flex-1 truncate">{task.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">{team}</span>
</div>
))}
{totals.remaining === 0 && <p className="text-sm text-muted-foreground">🎉 Nothing remaining everything is done.</p>}
</div>
</>
)}
</div>
</AppShell>
)
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border bg-card/50 p-3 backdrop-blur-sm">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold">{value}</p>
</div>
)
}
+136
View File
@@ -0,0 +1,136 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router'
import { ArrowRight, Check, Circle, Rocket } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
import { useAuth } from '@/store/auth'
interface Step {
title: string
desc: string
done: boolean
to: string
cta: string
}
export function GetStartedPage() {
const organizationId = useAuth((s) => s.organizationId)
const [steps, setSteps] = useState<Step[] | null>(null)
const load = useCallback(async () => {
if (!organizationId) return
try {
const [products, teams, configs] = await Promise.all([
api.get<{ id: string }[]>(`/api/orgboard/products?organizationId=${organizationId}`),
api.get<{ id: string; productId: string | null }[]>(`/api/orgboard/teams?organizationId=${organizationId}`),
api.get<{ id: string }[]>(`/api/integrations/api-configs?organizationId=${organizationId}`),
])
// Walk each team once for seats + board; cap the fan-out for big orgs.
const teamsToScan = teams.slice(0, 12)
const seatsByTeam = await Promise.all(
teamsToScan.map((t) => api.get<{ state: string }[]>(`/api/orgboard/seats?teamId=${t.id}`).catch(() => [])),
)
const boards = await Promise.all(
teamsToScan.map((t) =>
api.get<{ columns: { items: unknown[] }[] }>(`/api/orgboard/board?teamId=${t.id}`).catch(() => ({ columns: [] })),
),
)
const identities = await Promise.all(
products.slice(0, 12).map((p) =>
api.get<{ identity: string | null }>(`/api/orgboard/products/${p.id}/identity`).catch(() => ({ identity: null })),
),
)
const reviews = await api
.get<unknown[]>(`/api/governance/reviews?organizationId=${organizationId}`)
.catch(() => [])
const hasAiSeat = seatsByTeam.some((seats) => seats.some((s) => s.state === 'Ai'))
const hasTask = boards.some((b) => b.columns.some((c) => c.items.length > 0))
const hasIdentity = identities.some((i) => !!i.identity)
setSteps([
{ title: 'Workspace ready', desc: 'Your organization exists and you are signed in as its owner.', done: true, to: '/', cta: 'Open board' },
{ title: 'Model the org', desc: 'Create divisions → products/services → teams. A team runs a board.', done: teams.length > 0, to: '/structure', cta: 'Structure' },
{ title: 'Give the product an identity', desc: 'Write a PRODUCT.md brief — shared by every agent on the product.', done: hasIdentity, to: '/structure', cta: 'Set identity' },
{ title: 'Connect a model (BYOK)', desc: 'Add an API key (OpenAI-compatible). Owner-only, encrypted, never returned.', done: configs.length > 0, to: '/seats', cta: 'Add connection' },
{ title: 'Staff a seat with an AI agent', desc: 'Pick a role-seat, choose skills + autonomy + the model, and turn it AI.', done: hasAiSeat, to: '/seats', cta: 'Staff a seat' },
{ title: 'Fill the backlog', desc: 'Create tasks on the board — assign them to humans or AI agents.', done: hasTask, to: '/', cta: 'Create tasks' },
{ title: 'Review the first agent output', desc: 'Assign a task to an agent; its output waits in the review inbox to approve.', done: reviews.length > 0, to: '/reviews', cta: 'Review inbox' },
])
} catch (err) {
toast.error((err as Error).message)
}
}, [organizationId])
useEffect(() => {
void load()
}, [load])
const doneCount = steps?.filter((s) => s.done).length ?? 0
const total = steps?.length ?? 7
return (
<AppShell>
<div className="mx-auto max-w-2xl p-6">
<header className="mb-6">
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Rocket className="size-6" /> Get started
</h1>
<p className="text-sm text-muted-foreground">
Build a human + AI team from A to Z. {doneCount} of {total} steps done.
</p>
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-muted/60">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${(doneCount / total) * 100}%` }}
/>
</div>
</header>
<div className="flex flex-col gap-3">
{steps?.map((step, i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<span
className={cn(
'grid size-8 shrink-0 place-items-center rounded-full text-sm font-semibold',
step.done ? 'bg-approved/20 text-approved' : 'bg-muted text-muted-foreground',
)}
>
{step.done ? <Check className="size-4" /> : <Circle className="size-3.5" />}
</span>
<div className="min-w-0 flex-1">
<p className={cn('text-sm font-medium', step.done && 'text-muted-foreground line-through')}>
{i + 1}. {step.title}
</p>
<p className="text-xs text-muted-foreground">{step.desc}</p>
</div>
<Button asChild variant={step.done ? 'ghost' : 'outline'} size="sm" className="shrink-0">
<Link to={step.to}>
{step.done ? 'View' : step.cta}
<ArrowRight data-icon="inline-end" />
</Link>
</Button>
</CardContent>
</Card>
))}
{steps === null && <p className="text-sm text-muted-foreground">Checking your setup</p>}
</div>
{steps && doneCount === total && (
<Card className="mt-4">
<CardContent className="py-5 text-center text-sm">
🎉 Your human + AI team is running end to end. Mark a story <b>Done</b> to fire the POQA handoff,
and watch <Link to="/analytics" className="text-primary underline">Analytics</Link> for human edit distance.
</CardContent>
</Card>
)}
</div>
</AppShell>
)
}
+109
View File
@@ -0,0 +1,109 @@
import { useMemo, useState } from 'react'
import { BookOpen, ChevronDown, ChevronRight, Search } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { AppShell } from '@/components/AppShell'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { KB_ARTICLES, KB_CATEGORIES, type KbArticle } from '@/lib/kbArticles'
import '@/components/markdown.css'
export function KnowledgeBasePage() {
const [query, setQuery] = useState('')
const [open, setOpen] = useState<Set<string>>(new Set())
const matches = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return KB_ARTICLES
return KB_ARTICLES.filter((a) =>
`${a.title} ${a.summary} ${a.keywords} ${a.body}`.toLowerCase().includes(q),
)
}, [query])
const grouped = useMemo(() => {
return KB_CATEGORIES.map((category) => ({
category,
articles: matches.filter((a) => a.category === category),
})).filter((g) => g.articles.length > 0)
}, [matches])
const toggle = (id: string) =>
setOpen((s) => {
const next = new Set(s)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
return (
<AppShell>
<div className="mx-auto max-w-3xl p-6">
<header className="mb-5">
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<BookOpen className="size-6" /> Knowledge base
</h1>
<p className="text-sm text-muted-foreground">
How to work with TeamUp concepts, step-by-step guides, and examples.
</p>
</header>
<div className="relative mb-6">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search the knowledge base…"
className="pl-9"
/>
</div>
{grouped.length === 0 && (
<p className="text-sm text-muted-foreground">No articles match {query}.</p>
)}
<div className="flex flex-col gap-6">
{grouped.map((group) => (
<section key={group.category} className="flex flex-col gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{group.category}
</h2>
{group.articles.map((article) => (
<ArticleCard
key={article.id}
article={article}
open={open.has(article.id) || (!!query && matches.length <= 4)}
onToggle={() => toggle(article.id)}
/>
))}
</section>
))}
</div>
</div>
</AppShell>
)
}
function ArticleCard({ article, open, onToggle }: { article: KbArticle; open: boolean; onToggle: () => void }) {
return (
<Card>
<CardContent className="py-0">
<button
type="button"
onClick={onToggle}
className="flex w-full items-start gap-3 py-4 text-left"
>
{open ? <ChevronDown className="mt-0.5 size-4 shrink-0 text-muted-foreground" /> : <ChevronRight className="mt-0.5 size-4 shrink-0 text-muted-foreground" />}
<span className="min-w-0">
<span className="block text-sm font-medium">{article.title}</span>
<span className={cn('block text-xs text-muted-foreground', open && 'sr-only')}>{article.summary}</span>
</span>
</button>
{open && (
<div className="md-prose pb-5 pl-7">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{article.body}</ReactMarkdown>
</div>
)}
</CardContent>
</Card>
)
}
+483
View File
@@ -0,0 +1,483 @@
import { useCallback, useEffect, useState } from 'react'
import { ArrowRight, Check, GitBranch, Plus, Workflow } 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 } 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 Division {
id: string
name: string
}
interface ChangeSummary {
id: string
customerName: string
title: string
status: string
estimateHours: number | null
amount: number | null
currency: string
stepCount: number
doneStepCount: number
}
interface ChangeStep {
id: string
divisionId: string | null
divisionName: string | null
title: string
estimateHours: number
status: string
dependsOnStepId: string | null
order: number
}
interface ChangeDetail {
summary: ChangeSummary
description: string | null
totalStepHours: number
steps: ChangeStep[]
}
const STAGES = ['Requested', 'Estimated', 'Approved', 'Paid', 'Live'] as const
const STEP_STATUSES = ['Pending', 'InProgress', 'Done', 'Blocked'] as const
// The single commercial action available at each pipeline stage.
const ACTION: Record<string, { label: string; path: string } | null> = {
Requested: { label: 'Send estimate', path: 'estimate' },
Estimated: { label: 'Mark approved', path: 'approve' },
Approved: { label: 'Record payment', path: 'pay' },
Paid: { label: 'Go live', path: 'go-live' },
Live: null,
Rejected: null,
}
const STATUS_TONE: Record<string, string> = {
Requested: 'bg-muted text-muted-foreground',
Estimated: 'bg-sky-500/15 text-sky-700 dark:text-sky-400',
Approved: 'bg-violet-500/15 text-violet-700 dark:text-violet-400',
Paid: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
Live: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
Rejected: 'bg-destructive/15 text-destructive',
}
export function PipelinePage() {
const organizationId = useAuth((s) => s.organizationId)
const [requests, setRequests] = useState<ChangeSummary[]>([])
const [divisions, setDivisions] = useState<Division[]>([])
const [openId, setOpenId] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const load = useCallback(async () => {
if (!organizationId) return
try {
const [list, divs] = await Promise.all([
api.get<ChangeSummary[]>(`/api/orgboard/change-requests?organizationId=${organizationId}`),
api.get<Division[]>(`/api/orgboard/divisions?organizationId=${organizationId}`).catch(() => []),
])
setRequests(Array.isArray(list) ? list : [])
setDivisions(Array.isArray(divs) ? divs : [])
} catch (err) {
toast.error((err as Error).message)
}
}, [organizationId])
useEffect(() => {
void load()
}, [load])
return (
<AppShell>
<div className="mx-auto max-w-4xl p-6">
<header className="mb-5 flex items-center justify-between gap-4">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Workflow className="size-6" /> Delivery pipeline
</h1>
<p className="text-sm text-muted-foreground">
Customer change requests across divisions: estimate approve pay go-live.
</p>
</div>
<Button size="sm" onClick={() => setCreating((v) => !v)}>
<Plus data-icon="inline-start" /> New request
</Button>
</header>
{creating && organizationId && (
<NewRequestForm
organizationId={organizationId}
onCreated={() => {
setCreating(false)
void load()
}}
/>
)}
{/* Pipeline legend */}
<div className="mb-5 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
{STAGES.map((s, i) => (
<span key={s} className="flex items-center gap-1.5">
<span className="rounded-full bg-muted px-2 py-0.5 font-medium">{s}</span>
{i < STAGES.length - 1 && <ArrowRight className="size-3" />}
</span>
))}
</div>
<div className="flex flex-col gap-2">
{requests.map((cr) => {
const pct = cr.stepCount > 0 ? Math.round((cr.doneStepCount / cr.stepCount) * 100) : 0
return (
<button
key={cr.id}
type="button"
onClick={() => setOpenId(cr.id)}
className="flex items-center gap-3 rounded-xl border bg-card/60 px-4 py-3 text-left backdrop-blur-sm transition-colors hover:border-ring/60"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{cr.title}</span>
<span className={`shrink-0 rounded-md px-2 py-0.5 text-xs font-medium ${STATUS_TONE[cr.status] ?? 'bg-muted'}`}>
{cr.status}
</span>
</div>
<p className="truncate text-xs text-muted-foreground">
{cr.customerName}
{cr.estimateHours != null && ` · ${cr.estimateHours}h`}
{cr.amount != null && ` · ${cr.amount.toLocaleString()} ${cr.currency}`}
{cr.stepCount > 0 && ` · ${cr.doneStepCount}/${cr.stepCount} steps`}
</p>
</div>
{cr.stepCount > 0 && (
<div className="hidden h-2 w-24 shrink-0 overflow-hidden rounded-full bg-muted/60 sm:block">
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${pct}%` }} />
</div>
)}
</button>
)
})}
{requests.length === 0 && (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
No change requests yet. Log a customer ask to start the pipeline.
</CardContent>
</Card>
)}
</div>
</div>
{openId && (
<RequestDrawer
id={openId}
divisions={divisions}
onClose={() => setOpenId(null)}
onChanged={load}
/>
)}
</AppShell>
)
}
function NewRequestForm({ organizationId, onCreated }: { organizationId: string; onCreated: () => void }) {
const [customer, setCustomer] = useState('')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [busy, setBusy] = useState(false)
async function submit() {
if (!customer.trim() || !title.trim()) {
toast.error('Customer and title are required.')
return
}
setBusy(true)
try {
await api.post('/api/orgboard/change-requests', { organizationId, customerName: customer, title, description })
toast.success('Change request logged.')
onCreated()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
return (
<div className="mb-5 flex flex-col gap-3 rounded-xl border bg-card/60 p-4 backdrop-blur-sm">
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label>Customer</Label>
<Input value={customer} onChange={(e) => setCustomer(e.target.value)} placeholder="Acme Corp" />
</div>
<div className="flex flex-col gap-1.5">
<Label>Title</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Add SSO to the portal" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label>What they're asking for</Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
</div>
<div className="flex justify-end">
<Button size="sm" disabled={busy} onClick={submit}>Log request</Button>
</div>
</div>
)
}
function RequestDrawer({
id,
divisions,
onClose,
onChanged,
}: {
id: string
divisions: Division[]
onClose: () => void
onChanged: () => void
}) {
const [detail, setDetail] = useState<ChangeDetail | null>(null)
const [busy, setBusy] = useState(false)
const [amount, setAmount] = useState('')
const [currency, setCurrency] = useState('USD')
const reload = useCallback(async () => {
try {
setDetail(await api.get<ChangeDetail>(`/api/orgboard/change-requests/${id}`))
} catch (err) {
toast.error((err as Error).message)
}
}, [id])
useEffect(() => {
void reload()
}, [reload])
async function act(fn: () => Promise<ChangeDetail>, success?: string) {
setBusy(true)
try {
setDetail(await fn())
if (success) toast.success(success)
onChanged()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
const status = detail?.summary.status ?? 'Requested'
const action = ACTION[status]
const stageIndex = STAGES.indexOf(status as (typeof STAGES)[number])
return (
<Sheet open onOpenChange={(open) => !open && onClose()}>
<SheetContent className="overflow-y-auto sm:max-w-xl">
{detail && (
<>
<SheetHeader>
<div className="flex items-center gap-2">
<span className={`rounded-md px-2 py-0.5 text-xs font-medium ${STATUS_TONE[status] ?? 'bg-muted'}`}>{status}</span>
<Badge variant="outline">{detail.summary.customerName}</Badge>
</div>
<SheetTitle>{detail.summary.title}</SheetTitle>
{detail.description && <SheetDescription>{detail.description}</SheetDescription>}
</SheetHeader>
{/* Stage stepper */}
<div className="flex items-center gap-1 px-4">
{STAGES.map((s, i) => {
const done = stageIndex >= 0 && i <= stageIndex
return (
<div key={s} className="flex flex-1 flex-col items-center gap-1">
<div className="flex w-full items-center">
<span className={`grid size-6 shrink-0 place-items-center rounded-full border text-[10px] ${done ? 'border-emerald-500 bg-emerald-500/15 text-emerald-600' : 'border-border bg-muted/40 text-muted-foreground'}`}>
{done ? <Check className="size-3" /> : i + 1}
</span>
{i < STAGES.length - 1 && <span className={`h-0.5 flex-1 ${stageIndex > i ? 'bg-emerald-500' : 'bg-border'}`} />}
</div>
<span className="text-[10px] text-muted-foreground">{s}</span>
</div>
)
})}
</div>
{/* Totals + commercial action */}
<div className="mx-4 flex flex-col gap-3 rounded-xl border bg-card/60 p-4 backdrop-blur-sm">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Total estimate</span>
<span className="font-semibold">{detail.totalStepHours}h{detail.summary.amount != null && ` · ${detail.summary.amount.toLocaleString()} ${detail.summary.currency}`}</span>
</div>
{status === 'Requested' && (
<div className="flex items-end gap-2">
<div className="flex flex-1 flex-col gap-1">
<Label className="text-xs">Quote amount (optional)</Label>
<Input value={amount} onChange={(e) => setAmount(e.target.value)} inputMode="decimal" placeholder="12000" />
</div>
<div className="flex w-24 flex-col gap-1">
<Label className="text-xs">Currency</Label>
<Input value={currency} onChange={(e) => setCurrency(e.target.value)} />
</div>
</div>
)}
{action && (
<Button
disabled={busy}
onClick={() =>
act(
() =>
api.post<ChangeDetail>(`/api/orgboard/change-requests/${id}/${action.path}`,
action.path === 'estimate'
? { amount: amount ? Number(amount) : null, currency }
: {}),
`${action.label} done.`,
)
}
>
{action.label} <ArrowRight data-icon="inline-end" />
</Button>
)}
{status !== 'Live' && status !== 'Rejected' && (
<Button
variant="ghost"
size="sm"
disabled={busy}
className="text-destructive hover:text-destructive"
onClick={() => act(() => api.post<ChangeDetail>(`/api/orgboard/change-requests/${id}/reject`, {}), 'Rejected.')}
>
Reject request
</Button>
)}
</div>
{/* Steps */}
<div className="flex flex-col gap-2 px-4 pb-6">
<Label className="flex items-center gap-1.5"><GitBranch className="size-4" /> Cross-division steps</Label>
{detail.steps.map((step) => (
<div key={step.id} className="flex items-center gap-2 rounded-lg border bg-card/50 px-3 py-2 text-sm backdrop-blur-sm">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{step.title}</p>
<p className="text-xs text-muted-foreground">
{step.divisionName ?? 'Unassigned'} · {step.estimateHours}h
{step.dependsOnStepId && ' · depends on a prior step'}
</p>
</div>
<Select value={step.status} onValueChange={(v) => act(() => api.patch<ChangeDetail>(`/api/orgboard/change-requests/${id}/steps/${step.id}`, { status: v }))}>
<SelectTrigger className="h-8 w-32 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
{STEP_STATUSES.map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
</div>
))}
{detail.steps.length === 0 && <p className="text-xs text-muted-foreground">No steps yet — break the work down by division below.</p>}
<AddStepForm id={id} divisions={divisions} steps={detail.steps} onAdded={(d) => setDetail(d)} />
</div>
</>
)}
</SheetContent>
</Sheet>
)
}
function AddStepForm({
id,
divisions,
steps,
onAdded,
}: {
id: string
divisions: Division[]
steps: ChangeStep[]
onAdded: (detail: ChangeDetail) => void
}) {
const [title, setTitle] = useState('')
const [hours, setHours] = useState('')
const [divisionId, setDivisionId] = useState<string>('')
const [dependsOn, setDependsOn] = useState<string>('')
const [busy, setBusy] = useState(false)
async function submit() {
if (!title.trim() || !hours) {
toast.error('Step title and hours are required.')
return
}
setBusy(true)
try {
const detail = await api.post<ChangeDetail>(`/api/orgboard/change-requests/${id}/steps`, {
title,
estimateHours: Number(hours),
divisionId: divisionId || null,
dependsOnStepId: dependsOn || null,
})
onAdded(detail)
setTitle('')
setHours('')
setDivisionId('')
setDependsOn('')
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
return (
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-dashed p-3">
<div className="flex gap-2">
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Step (e.g. Build SSO connector)" className="flex-1" />
<Input value={hours} onChange={(e) => setHours(e.target.value)} inputMode="decimal" placeholder="Hours" className="w-24" />
</div>
<div className="flex gap-2">
<Select value={divisionId} onValueChange={setDivisionId}>
<SelectTrigger className="flex-1"><SelectValue placeholder="Division (optional)" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{divisions.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
{steps.length > 0 && (
<Select value={dependsOn} onValueChange={setDependsOn}>
<SelectTrigger className="flex-1"><SelectValue placeholder="Depends on (optional)" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{steps.map((s) => <SelectItem key={s.id} value={s.id}>{s.title}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
)}
</div>
<div className="flex justify-end">
<Button size="sm" variant="outline" disabled={busy} onClick={submit}>
<Plus data-icon="inline-start" /> Add step
</Button>
</div>
</div>
)
}
+2
View File
@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { REVIEWS_CHANGED } from '@/components/AppShell'
import { AgentFace } from '@/components/AgentFace' import { AgentFace } from '@/components/AgentFace'
import { MarkdownEditor } from '@/components/MarkdownEditor' import { MarkdownEditor } from '@/components/MarkdownEditor'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@@ -169,6 +170,7 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
toast.info('Sent back to the agent') toast.info('Sent back to the agent')
} }
onDecided(item.id) onDecided(item.id)
window.dispatchEvent(new Event(REVIEWS_CHANGED))
} catch (err) { } catch (err) {
toast.error((err as Error).message) toast.error((err as Error).message)
} finally { } finally {
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.Assembler.Domain; using TeamUp.Modules.Assembler.Domain;
using TeamUp.Modules.Assembler.Persistence; using TeamUp.Modules.Assembler.Persistence;
using TeamUp.Modules.Assembler.Queue; using TeamUp.Modules.Assembler.Queue;
@@ -12,6 +13,18 @@ internal sealed class AgentRunDispatcher(AssemblerDbContext db, JobQueue queue,
{ {
public async Task<Guid> DispatchAsync(Guid seatId, Guid workItemId, CancellationToken cancellationToken = default) public async Task<Guid> DispatchAsync(Guid seatId, Guid workItemId, CancellationToken cancellationToken = default)
{ {
// Defensive: never stack a duplicate run while one is already in flight for this (seat, task).
// Repeated "Run" clicks return the in-flight run instead of dispatching another model call.
var inFlight = await db.AgentRuns
.Where(r => r.SeatId == seatId && r.WorkItemId == workItemId
&& (r.Status == AgentRunStatus.Queued || r.Status == AgentRunStatus.Running))
.Select(r => (Guid?)r.Id)
.FirstOrDefaultAsync(cancellationToken);
if (inFlight is { } existing)
{
return existing;
}
var run = new AgentRun(seatId, workItemId, clock.GetUtcNow()); var run = new AgentRun(seatId, workItemId, clock.GetUtcNow());
db.AgentRuns.Add(run); db.AgentRuns.Add(run);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -94,12 +94,20 @@ internal sealed class AgentRunExecutor(
run.Complete(output, assembled.PrimaryAction, assembled.PrimaryActionRisk, result, completion.LatencyMs, clock.GetUtcNow()); run.Complete(output, assembled.PrimaryAction, assembled.PrimaryActionRisk, result, completion.LatencyMs, clock.GetUtcNow());
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
// Only a spec / story-breakdown agent proposes child stories. Other actions (code, design,
// tests) produce an artifact, not a backlog — so don't mistake a numbered list in their
// output (e.g. a list of file names) for child tasks.
var primarySkill = context.SkillKeys.Count > 0 ? context.SkillKeys[0] : null;
var childTitles = primarySkill is "spec-writing" or "story-breakdown"
? OutputParser.ExtractChildTitles(output)
: Array.Empty<string>();
// Hand the parsed action to the gate: autonomy vs risk → execute now or hold in review. // Hand the parsed action to the gate: autonomy vs risk → execute now or hold in review.
var gate = await actionGate.EvaluateAsync( var gate = await actionGate.EvaluateAsync(
new AgentActionProposal( new AgentActionProposal(
run.Id, run.SeatId, context.AgentId, run.WorkItemId, context.TeamId, context.OrganizationId, run.Id, run.SeatId, context.AgentId, run.WorkItemId, context.TeamId, context.OrganizationId,
context.Autonomy, assembled.PrimaryAction, assembled.PrimaryActionRisk, context.Autonomy, assembled.PrimaryAction, assembled.PrimaryActionRisk,
context.TaskTitle, output, OutputParser.ExtractChildTitles(output), assembled.Trace), context.TaskTitle, output, childTitles, assembled.Trace),
cancellationToken); cancellationToken);
logger.LogInformation( logger.LogInformation(
"Run {RunId}: {Action} ({Risk}) → {Outcome}.", "Run {RunId}: {Action} ({Risk}) → {Outcome}.",
@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.Governance.Domain; using TeamUp.Modules.Governance.Domain;
using TeamUp.Modules.Governance.Persistence; using TeamUp.Modules.Governance.Persistence;
using TeamUp.SharedKernel.Access; using TeamUp.SharedKernel.Access;
@@ -25,6 +26,20 @@ internal sealed class ActionGate(
if (GatePolicy.ShouldHold(proposal.Autonomy, risk)) if (GatePolicy.ShouldHold(proposal.Autonomy, risk))
{ {
// Defensive: collapse duplicates. If an identical action is already pending for this
// (task, agent), keep that one rather than stacking another copy in the review inbox.
var existing = await db.ReviewItems
.Where(r => r.WorkItemId == proposal.WorkItemId
&& r.AgentId == proposal.AgentId
&& r.ActionKind == proposal.ActionKind
&& r.Status == ReviewStatus.Pending)
.Select(r => (Guid?)r.Id)
.FirstOrDefaultAsync(cancellationToken);
if (existing is { } existingId)
{
return new GateResult(GateOutcome.Held, existingId);
}
var item = new ReviewItem(proposal, clock.GetUtcNow()); var item = new ReviewItem(proposal, clock.GetUtcNow());
db.ReviewItems.Add(item); db.ReviewItems.Add(item);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -0,0 +1,113 @@
using TeamUp.SharedKernel.Domain;
namespace TeamUp.Modules.OrgBoard.Domain;
/// <summary>Where a customer change request sits in the commercial delivery pipeline.</summary>
internal enum ChangeRequestStatus
{
Requested, // logged from the customer, not yet scoped
Estimated, // hours + price quoted, awaiting the customer's decision
Approved, // customer approved the quote — cleared to schedule work
Paid, // payment received — cleared to go live
Live, // delivered / live for the customer
Rejected, // customer declined, or we won't do it
}
/// <summary>
/// A customer change request flowing across divisions: logged → estimated (total hours + price) →
/// approved → paid → live. The cross-division work and its dependencies live on the request's steps
/// (<see cref="ChangeRequestStep"/>); the request itself owns the commercial pipeline and its guards —
/// you can't approve before estimating, take payment before approval, or go live before payment.
/// </summary>
internal sealed class ChangeRequest : Entity
{
public Guid OrganizationId { get; private set; }
public string CustomerName { get; private set; } = null!;
public string Title { get; private set; } = null!;
public string? Description { get; private set; }
public ChangeRequestStatus Status { get; private set; }
public decimal? EstimateHours { get; private set; }
public decimal? Amount { get; private set; }
public string Currency { get; private set; } = "USD";
public DateTimeOffset CreatedAtUtc { get; private set; }
public DateTimeOffset UpdatedAtUtc { get; private set; }
public DateTimeOffset? EstimatedAtUtc { get; private set; }
public DateTimeOffset? ApprovedAtUtc { get; private set; }
public DateTimeOffset? PaidAtUtc { get; private set; }
public DateTimeOffset? LiveAtUtc { get; private set; }
private ChangeRequest()
{
}
public ChangeRequest(Guid organizationId, string customerName, string title, string? description, DateTimeOffset nowUtc)
{
OrganizationId = organizationId;
CustomerName = customerName;
Title = title;
Description = description;
Status = ChangeRequestStatus.Requested;
CreatedAtUtc = nowUtc;
UpdatedAtUtc = nowUtc;
}
/// <summary>Quote the request: lock in the total hours (summed from its steps) and an optional price.</summary>
public void Estimate(decimal totalHours, decimal? amount, string? currency, DateTimeOffset nowUtc)
{
Require(ChangeRequestStatus.Requested, ChangeRequestStatus.Estimated);
EstimateHours = totalHours;
Amount = amount;
if (!string.IsNullOrWhiteSpace(currency))
{
Currency = currency.Trim().ToUpperInvariant();
}
Status = ChangeRequestStatus.Estimated;
EstimatedAtUtc = nowUtc;
UpdatedAtUtc = nowUtc;
}
public void Approve(DateTimeOffset nowUtc)
{
Require(ChangeRequestStatus.Estimated);
Status = ChangeRequestStatus.Approved;
ApprovedAtUtc = nowUtc;
UpdatedAtUtc = nowUtc;
}
public void RecordPayment(DateTimeOffset nowUtc)
{
Require(ChangeRequestStatus.Approved);
Status = ChangeRequestStatus.Paid;
PaidAtUtc = nowUtc;
UpdatedAtUtc = nowUtc;
}
public void GoLive(DateTimeOffset nowUtc)
{
Require(ChangeRequestStatus.Paid);
Status = ChangeRequestStatus.Live;
LiveAtUtc = nowUtc;
UpdatedAtUtc = nowUtc;
}
public void Reject(DateTimeOffset nowUtc)
{
if (Status is ChangeRequestStatus.Live or ChangeRequestStatus.Rejected)
{
throw new InvalidOperationException($"A {Status} change request can't be rejected.");
}
Status = ChangeRequestStatus.Rejected;
UpdatedAtUtc = nowUtc;
}
private void Require(params ChangeRequestStatus[] allowed)
{
if (Array.IndexOf(allowed, Status) < 0)
{
throw new InvalidOperationException(
$"Change request is {Status}; this step requires {string.Join(" or ", allowed)}.");
}
}
}
@@ -0,0 +1,65 @@
using TeamUp.SharedKernel.Domain;
namespace TeamUp.Modules.OrgBoard.Domain;
internal enum ChangeStepStatus
{
Pending,
InProgress,
Done,
Blocked,
}
/// <summary>
/// One unit of cross-division work on a change request: a division's slice, its hours estimate, and an
/// optional dependency on an earlier step (e.g. Ops can't go live until Engineering's step is done).
/// The chain of <see cref="DependsOnStepId"/> links is how a request models "a lot of dependencies".
/// </summary>
internal sealed class ChangeRequestStep : Entity
{
public Guid ChangeRequestId { get; private set; }
public Guid? DivisionId { get; private set; }
public string Title { get; private set; } = null!;
public decimal EstimateHours { get; private set; }
public ChangeStepStatus Status { get; private set; }
public Guid? DependsOnStepId { get; private set; }
public int Order { get; private set; }
public DateTimeOffset CreatedAtUtc { get; private set; }
public DateTimeOffset UpdatedAtUtc { get; private set; }
private ChangeRequestStep()
{
}
public ChangeRequestStep(
Guid changeRequestId,
Guid? divisionId,
string title,
decimal estimateHours,
Guid? dependsOnStepId,
int order,
DateTimeOffset nowUtc)
{
ChangeRequestId = changeRequestId;
DivisionId = divisionId;
Title = title;
EstimateHours = estimateHours;
Status = ChangeStepStatus.Pending;
DependsOnStepId = dependsOnStepId;
Order = order;
CreatedAtUtc = nowUtc;
UpdatedAtUtc = nowUtc;
}
public void Advance(ChangeStepStatus status, DateTimeOffset nowUtc)
{
Status = status;
UpdatedAtUtc = nowUtc;
}
public void SetEstimate(decimal estimateHours, DateTimeOffset nowUtc)
{
EstimateHours = estimateHours;
UpdatedAtUtc = nowUtc;
}
}
@@ -69,6 +69,9 @@ internal sealed class WorkItem : Entity
UpdatedAtUtc = nowUtc; UpdatedAtUtc = nowUtc;
} }
/// <summary>Detach from a parent (used when the parent is deleted, so the child stays on the board).</summary>
public void ClearParent() => ParentId = null;
/// <summary>Appends an approved agent artifact (spec / test plan) to the task.</summary> /// <summary>Appends an approved agent artifact (spec / test plan) to the task.</summary>
public void AttachArtifact(string content, DateTimeOffset nowUtc) public void AttachArtifact(string content, DateTimeOffset nowUtc)
{ {
@@ -0,0 +1,288 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.OrgBoard.Domain;
using TeamUp.Modules.OrgBoard.Persistence;
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Auditing;
namespace TeamUp.Modules.OrgBoard.Endpoints;
/// <summary>
/// The cross-division delivery pipeline: a customer change request is logged, scoped into per-division
/// steps (with hours + dependencies), then advanced through estimate → approve → pay → go-live. Each
/// commercial transition is guarded on the aggregate, so the pipeline can only move forward in order.
/// Reads need board-view; the commercial actions are owner-level (same capability as shaping the org).
/// </summary>
internal static class ChangeRequestEndpoints
{
public static void MapTo(RouteGroupBuilder group)
{
group.MapPost("/change-requests", Create).RequireAuthorization();
group.MapGet("/change-requests", List).RequireAuthorization();
group.MapGet("/change-requests/{id:guid}", Get).RequireAuthorization();
group.MapPost("/change-requests/{id:guid}/steps", AddStep).RequireAuthorization();
group.MapPatch("/change-requests/{id:guid}/steps/{stepId:guid}", AdvanceStep).RequireAuthorization();
group.MapPost("/change-requests/{id:guid}/estimate", Estimate).RequireAuthorization();
group.MapPost("/change-requests/{id:guid}/approve", Approve).RequireAuthorization();
group.MapPost("/change-requests/{id:guid}/pay", Pay).RequireAuthorization();
group.MapPost("/change-requests/{id:guid}/go-live", GoLive).RequireAuthorization();
group.MapPost("/change-requests/{id:guid}/reject", Reject).RequireAuthorization();
}
private static async Task<IResult> Create(
CreateChangeRequestRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId)))
{
return Results.Forbid();
}
if (string.IsNullOrWhiteSpace(request.CustomerName) || string.IsNullOrWhiteSpace(request.Title))
{
return Results.BadRequest("Customer and title are required.");
}
var cr = new ChangeRequest(
request.OrganizationId, request.CustomerName.Trim(), request.Title.Trim(),
string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), clock.GetUtcNow());
db.ChangeRequests.Add(cr);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("change-request.created", "ChangeRequest", cr.Id, user.MemberId, cr.Title), ct);
return Results.Ok(ToSummary(cr, steps: [], doneSteps: 0));
}
private static async Task<IResult> List(
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
{
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(organizationId)))
{
return Results.Forbid();
}
var requests = await db.ChangeRequests
.Where(c => c.OrganizationId == organizationId)
.OrderByDescending(c => c.CreatedAtUtc)
.ToListAsync(ct);
var ids = requests.Select(c => c.Id).ToList();
var steps = await db.ChangeRequestSteps.Where(s => ids.Contains(s.ChangeRequestId)).ToListAsync(ct);
var byRequest = steps.GroupBy(s => s.ChangeRequestId).ToDictionary(g => g.Key, g => g.ToList());
var summaries = requests
.Select(c =>
{
var its = byRequest.TryGetValue(c.Id, out var list) ? list : [];
return ToSummary(c, its, its.Count(s => s.Status == ChangeStepStatus.Done));
})
.ToList();
return Results.Ok(summaries);
}
private static async Task<IResult> Get(
Guid id, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
{
var cr = await db.ChangeRequests.FirstOrDefaultAsync(c => c.Id == id, ct);
if (cr is null)
{
return Results.NotFound();
}
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(cr.OrganizationId)))
{
return Results.Forbid();
}
var steps = await db.ChangeRequestSteps
.Where(s => s.ChangeRequestId == id)
.OrderBy(s => s.Order)
.ToListAsync(ct);
var divisions = await db.Divisions
.Where(d => d.OrganizationId == cr.OrganizationId)
.ToDictionaryAsync(d => d.Id, d => d.Name, ct);
return Results.Ok(ToDetail(cr, steps, divisions));
}
private static async Task<IResult> AddStep(
Guid id, AddChangeStepRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var cr = await LoadForWrite(db, permissions, id, ct);
if (cr.Error is not null)
{
return cr.Error;
}
if (string.IsNullOrWhiteSpace(request.Title))
{
return Results.BadRequest("Step title is required.");
}
if (request.DivisionId is { } divisionId
&& !await db.Divisions.AnyAsync(d => d.Id == divisionId && d.OrganizationId == cr.Request!.OrganizationId, ct))
{
return Results.BadRequest("Division not found in this organization.");
}
var order = await db.ChangeRequestSteps.CountAsync(s => s.ChangeRequestId == id, ct);
var step = new ChangeRequestStep(
id, request.DivisionId, request.Title.Trim(), request.EstimateHours,
request.DependsOnStepId, order, clock.GetUtcNow());
db.ChangeRequestSteps.Add(step);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("change-request.step-added", "ChangeRequest", id, user.MemberId, step.Title), ct);
return Results.Ok(await BuildDetail(db, cr.Request!, ct));
}
private static async Task<IResult> AdvanceStep(
Guid id, Guid stepId, AdvanceStepRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var cr = await LoadForWrite(db, permissions, id, ct);
if (cr.Error is not null)
{
return cr.Error;
}
var step = await db.ChangeRequestSteps.FirstOrDefaultAsync(s => s.Id == stepId && s.ChangeRequestId == id, ct);
if (step is null)
{
return Results.NotFound("Step not found.");
}
step.Advance(request.Status, clock.GetUtcNow());
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("change-request.step-advanced", "ChangeRequest", id, user.MemberId, request.Status.ToString()), ct);
return Results.Ok(await BuildDetail(db, cr.Request!, ct));
}
private static async Task<IResult> Estimate(
Guid id, EstimateChangeRequestRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var cr = await LoadForWrite(db, permissions, id, ct);
if (cr.Error is not null)
{
return cr.Error;
}
var steps = await db.ChangeRequestSteps.Where(s => s.ChangeRequestId == id).ToListAsync(ct);
if (steps.Count == 0)
{
return Results.BadRequest("Add at least one step before estimating.");
}
var totalHours = steps.Sum(s => s.EstimateHours);
return await Transition(
db, audit, user, cr.Request!, c => c.Estimate(totalHours, request.Amount, request.Currency, clock.GetUtcNow()),
"change-request.estimated", $"{totalHours}h", ct);
}
private static Task<IResult> Approve(
Guid id, ICurrentUser user, IPermissionService permissions, IAuditLog audit,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) =>
SimpleTransition(id, user, permissions, audit, db, c => c.Approve(clock.GetUtcNow()), "change-request.approved", ct);
private static Task<IResult> Pay(
Guid id, ICurrentUser user, IPermissionService permissions, IAuditLog audit,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) =>
SimpleTransition(id, user, permissions, audit, db, c => c.RecordPayment(clock.GetUtcNow()), "change-request.paid", ct);
private static Task<IResult> GoLive(
Guid id, ICurrentUser user, IPermissionService permissions, IAuditLog audit,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) =>
SimpleTransition(id, user, permissions, audit, db, c => c.GoLive(clock.GetUtcNow()), "change-request.live", ct);
private static Task<IResult> Reject(
Guid id, ICurrentUser user, IPermissionService permissions, IAuditLog audit,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) =>
SimpleTransition(id, user, permissions, audit, db, c => c.Reject(clock.GetUtcNow()), "change-request.rejected", ct);
private static async Task<IResult> SimpleTransition(
Guid id, ICurrentUser user, IPermissionService permissions, IAuditLog audit, OrgBoardDbContext db,
Action<ChangeRequest> apply, string auditAction, CancellationToken ct)
{
var cr = await LoadForWrite(db, permissions, id, ct);
if (cr.Error is not null)
{
return cr.Error;
}
return await Transition(db, audit, user, cr.Request!, apply, auditAction, cr.Request!.Status.ToString(), ct);
}
// Apply a guarded pipeline transition, turning the domain's guard violation into a 400.
private static async Task<IResult> Transition(
OrgBoardDbContext db, IAuditLog audit, ICurrentUser user, ChangeRequest cr,
Action<ChangeRequest> apply, string auditAction, string detail, CancellationToken ct)
{
try
{
apply(cr);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(ex.Message);
}
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent(auditAction, "ChangeRequest", cr.Id, user.MemberId, detail), ct);
return Results.Ok(await BuildDetail(db, cr, ct));
}
private static async Task<(ChangeRequest? Request, IResult? Error)> LoadForWrite(
OrgBoardDbContext db, IPermissionService permissions, Guid id, CancellationToken ct)
{
var cr = await db.ChangeRequests.FirstOrDefaultAsync(c => c.Id == id, ct);
if (cr is null)
{
return (null, Results.NotFound());
}
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(cr.OrganizationId)))
{
return (null, Results.Forbid());
}
return (cr, null);
}
private static async Task<ChangeRequestDetail> BuildDetail(OrgBoardDbContext db, ChangeRequest cr, CancellationToken ct)
{
var steps = await db.ChangeRequestSteps
.Where(s => s.ChangeRequestId == cr.Id)
.OrderBy(s => s.Order)
.ToListAsync(ct);
var divisions = await db.Divisions
.Where(d => d.OrganizationId == cr.OrganizationId)
.ToDictionaryAsync(d => d.Id, d => d.Name, ct);
return ToDetail(cr, steps, divisions);
}
private static ChangeRequestSummary ToSummary(ChangeRequest cr, IReadOnlyList<ChangeRequestStep> steps, int doneSteps) =>
new(cr.Id, cr.OrganizationId, cr.CustomerName, cr.Title, cr.Status.ToString(),
cr.EstimateHours, cr.Amount, cr.Currency, steps.Count, doneSteps);
private static ChangeRequestDetail ToDetail(
ChangeRequest cr, IReadOnlyList<ChangeRequestStep> steps, Dictionary<Guid, string> divisions) =>
new(
ToSummary(cr, steps, steps.Count(s => s.Status == ChangeStepStatus.Done)),
cr.Description,
steps.Sum(s => s.EstimateHours),
steps.Select(s => new ChangeStepResponse(
s.Id,
s.DivisionId,
s.DivisionId is { } d && divisions.TryGetValue(d, out var name) ? name : null,
s.Title,
s.EstimateHours,
s.Status.ToString(),
s.DependsOnStepId,
s.Order)).ToList(),
cr.CreatedAtUtc,
cr.EstimatedAtUtc,
cr.ApprovedAtUtc,
cr.PaidAtUtc,
cr.LiveAtUtc);
}
@@ -29,6 +29,13 @@ internal sealed record MoveTaskRequest(WorkItemStatus Status);
internal sealed record AssignTaskRequest(Guid MemberId); internal sealed record AssignTaskRequest(Guid MemberId);
// Autopilot: fan a parent task's outstanding children out to the team's AI seats in one shot.
internal sealed record RunAllRequest(Guid? SeatId = null);
internal sealed record RunDispatch(Guid WorkItemId, string Title, Guid SeatId, Guid RunId);
internal sealed record RunAllResponse(int Dispatched, IReadOnlyList<RunDispatch> Runs);
internal sealed record TaskResponse( internal sealed record TaskResponse(
Guid Id, Guid Id,
Guid TeamId, Guid TeamId,
@@ -72,6 +79,49 @@ internal sealed record AgentResponse(
List<string> Docs, List<string> Docs,
string? Persona); string? Persona);
// --- Cross-division delivery pipeline: customer change requests ---
internal sealed record CreateChangeRequestRequest(Guid OrganizationId, string CustomerName, string Title, string? Description);
internal sealed record AddChangeStepRequest(string Title, decimal EstimateHours, Guid? DivisionId = null, Guid? DependsOnStepId = null);
internal sealed record EstimateChangeRequestRequest(decimal? Amount = null, string? Currency = null);
internal sealed record AdvanceStepRequest(ChangeStepStatus Status);
internal sealed record ChangeStepResponse(
Guid Id,
Guid? DivisionId,
string? DivisionName,
string Title,
decimal EstimateHours,
string Status,
Guid? DependsOnStepId,
int Order);
internal sealed record ChangeRequestSummary(
Guid Id,
Guid OrganizationId,
string CustomerName,
string Title,
string Status,
decimal? EstimateHours,
decimal? Amount,
string Currency,
int StepCount,
int DoneStepCount);
internal sealed record ChangeRequestDetail(
ChangeRequestSummary Summary,
string? Description,
decimal TotalStepHours,
IReadOnlyList<ChangeStepResponse> Steps,
DateTimeOffset CreatedAtUtc,
DateTimeOffset? EstimatedAtUtc,
DateTimeOffset? ApprovedAtUtc,
DateTimeOffset? PaidAtUtc,
DateTimeOffset? LiveAtUtc);
// --- Agent profiles (AGENTS.md): a per-org library of reusable agent definitions --- // --- Agent profiles (AGENTS.md): a per-org library of reusable agent definitions ---
internal sealed record UploadAgentProfileRequest(Guid OrganizationId, string Content); internal sealed record UploadAgentProfileRequest(Guid OrganizationId, string Content);
@@ -1,3 +1,7 @@
using System.Globalization;
using System.IO.Compression;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
@@ -5,6 +9,7 @@ using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.OrgBoard.Domain; using TeamUp.Modules.OrgBoard.Domain;
using TeamUp.Modules.OrgBoard.Persistence; using TeamUp.Modules.OrgBoard.Persistence;
using TeamUp.SharedKernel.Access; using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Ai;
using TeamUp.SharedKernel.Auditing; using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Modularity; using TeamUp.SharedKernel.Modularity;
@@ -24,12 +29,15 @@ internal static class OrgBoardEndpoints
group.MapGet("/products", ListProducts).RequireAuthorization(); group.MapGet("/products", ListProducts).RequireAuthorization();
group.MapGet("/products/{id:guid}/identity", GetProductIdentity).RequireAuthorization(); group.MapGet("/products/{id:guid}/identity", GetProductIdentity).RequireAuthorization();
group.MapPut("/products/{id:guid}/identity", SetProductIdentity).RequireAuthorization(); group.MapPut("/products/{id:guid}/identity", SetProductIdentity).RequireAuthorization();
group.MapGet("/products/{id:guid}/export", ExportProduct).RequireAuthorization();
group.MapPost("/teams", CreateTeam).RequireAuthorization(); group.MapPost("/teams", CreateTeam).RequireAuthorization();
group.MapGet("/teams", ListTeams).RequireAuthorization(); group.MapGet("/teams", ListTeams).RequireAuthorization();
group.MapPost("/tasks", CreateTask).RequireAuthorization(); group.MapPost("/tasks", CreateTask).RequireAuthorization();
group.MapGet("/board", GetBoard).RequireAuthorization(); group.MapGet("/board", GetBoard).RequireAuthorization();
group.MapPatch("/tasks/{id:guid}/move", MoveTask).RequireAuthorization(); group.MapPatch("/tasks/{id:guid}/move", MoveTask).RequireAuthorization();
group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization(); group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization();
group.MapPost("/tasks/{id:guid}/run-all", RunAllChildren).RequireAuthorization();
group.MapDelete("/tasks/{id:guid}", DeleteTask).RequireAuthorization();
group.MapGet("/cartable", Cartable).RequireAuthorization(); group.MapGet("/cartable", Cartable).RequireAuthorization();
group.MapPost("/seats", CreateSeat).RequireAuthorization(); group.MapPost("/seats", CreateSeat).RequireAuthorization();
@@ -41,6 +49,7 @@ internal static class OrgBoardEndpoints
AgentProfileEndpoints.MapTo(group); AgentProfileEndpoints.MapTo(group);
ProductProfileEndpoints.MapTo(group); ProductProfileEndpoints.MapTo(group);
ChangeRequestEndpoints.MapTo(group);
} }
private static TaskResponse ToResponse(WorkItem item) => new( private static TaskResponse ToResponse(WorkItem item) => new(
@@ -229,6 +238,154 @@ internal static class OrgBoardEndpoints
return Results.Ok(new ProductIdentityResponse(product.Id, product.Name, product.Identity)); return Results.Ok(new ProductIdentityResponse(product.Id, product.Name, product.Identity));
} }
// Matches a fenced code block, capturing its language hint and body, so we can write a delivered
// artifact out as a real source file (App.tsx, schema.sql, …) instead of a wall of markdown.
private static readonly Regex FenceRx = new(
@"```(?<lang>[a-zA-Z0-9+#.]*)\s*\n(?<body>.*?)```",
RegexOptions.Singleline | RegexOptions.Compiled);
// Download the product as a project: PRODUCT.md + every team's delivered artifacts as files,
// plus a README manifest. This is the "an agent did the work, now I download the result" payoff —
// a portable bundle of what the team (human + AI) produced, gated on board-view permission.
private static async Task<IResult> ExportProduct(
Guid id, IPermissionService permissions, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var product = await db.Products.FirstOrDefaultAsync(p => p.Id == id, ct);
if (product is null)
{
return Results.NotFound();
}
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(product.OrganizationId)))
{
return Results.Forbid();
}
var teams = await db.Teams
.Where(t => t.ProductId == id)
.OrderBy(t => t.CreatedAtUtc)
.ToListAsync(ct);
var teamIds = teams.Select(t => t.Id).ToList();
var teamsById = teams.ToDictionary(t => t.Id);
// Only items that actually carry a delivered artifact are worth exporting.
var items = await db.WorkItems
.Where(w => teamIds.Contains(w.TeamId) && w.Description != null && w.Description != "")
.OrderBy(w => w.CreatedAtUtc)
.ToListAsync(ct);
using var buffer = new MemoryStream();
using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
{
var manifest = new StringBuilder();
manifest.Append("# ").Append(product.Name).Append("\n\n");
manifest.Append("_Exported from TeamUp on ")
.Append(clock.GetUtcNow().ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture)).Append("._\n\n");
manifest.Append(items.Count).Append(" delivered artifact(s) across ")
.Append(teams.Count).Append(" team(s).\n\n");
if (!string.IsNullOrWhiteSpace(product.Identity))
{
WriteEntry(zip, "PRODUCT.md", product.Identity!);
manifest.Append("- `PRODUCT.md` — product identity\n");
}
var counters = new Dictionary<Guid, int>();
foreach (var item in items)
{
var team = teamsById[item.TeamId];
var folder = Slug(team.Name);
var n = counters.TryGetValue(item.TeamId, out var c) ? c + 1 : 1;
counters[item.TeamId] = n;
var (ext, content) = RenderArtifact(item.Description!);
var path = $"{folder}/{n:D2}-{Slug(item.Title)}{ext}";
WriteEntry(zip, path, content);
manifest.Append("- `").Append(path).Append("` — ").Append(item.Title).Append('\n');
}
WriteEntry(zip, "README.md", manifest.ToString());
}
return Results.File(buffer.ToArray(), "application/zip", $"{Slug(product.Name)}.zip");
}
private static void WriteEntry(ZipArchive zip, string path, string content)
{
var entry = zip.CreateEntry(path, CompressionLevel.Optimal);
using var stream = entry.Open();
using var writer = new StreamWriter(stream, new UTF8Encoding(false));
writer.Write(content);
}
// If a delivered artifact is essentially one fenced code block, write it out as that source file;
// otherwise keep it as markdown.
private static (string Ext, string Content) RenderArtifact(string artifact)
{
var matches = FenceRx.Matches(artifact);
if (matches.Count > 0)
{
var largest = matches
.OrderByDescending(m => m.Groups["body"].Value.Length)
.First();
var body = largest.Groups["body"].Value;
if (body.Length >= artifact.Trim().Length * 0.5)
{
return (ExtensionFor(largest.Groups["lang"].Value), body.TrimEnd() + "\n");
}
}
return (".md", artifact);
}
private static string ExtensionFor(string lang) => lang.ToLowerInvariant() switch
{
"tsx" => ".tsx",
"ts" or "typescript" => ".ts",
"jsx" => ".jsx",
"js" or "javascript" => ".js",
"cs" or "csharp" => ".cs",
"py" or "python" => ".py",
"go" or "golang" => ".go",
"java" => ".java",
"rb" or "ruby" => ".rb",
"php" => ".php",
"rs" or "rust" => ".rs",
"sql" => ".sql",
"html" => ".html",
"css" => ".css",
"scss" => ".scss",
"json" => ".json",
"yaml" or "yml" => ".yml",
"sh" or "bash" or "shell" => ".sh",
"md" or "markdown" => ".md",
_ => ".txt",
};
// Filesystem-safe lower-kebab slug for folder/file names.
private static string Slug(string value)
{
var sb = new StringBuilder(value.Length);
foreach (var ch in value.Trim().ToLowerInvariant())
{
sb.Append(char.IsLetterOrDigit(ch) ? ch : '-');
}
var slug = sb.ToString();
while (slug.Contains("--", StringComparison.Ordinal))
{
slug = slug.Replace("--", "-", StringComparison.Ordinal);
}
slug = slug.Trim('-');
if (slug.Length == 0)
{
return "item";
}
return slug.Length > 50 ? slug[..50].Trim('-') : slug;
}
private static async Task<IResult> ListTeams( private static async Task<IResult> ListTeams(
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct) Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
{ {
@@ -273,6 +430,36 @@ internal static class OrgBoardEndpoints
return Results.Ok(ToResponse(item)); return Results.Ok(ToResponse(item));
} }
// Remove a task from the board. Its children are detached (kept as top-level) rather than deleted,
// and its status-transition history is dropped. Any agent runs/reviews it spawned are left as history.
private static async Task<IResult> DeleteTask(
Guid id, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, CancellationToken ct)
{
var item = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == id, ct);
if (item is null)
{
return Results.NotFound();
}
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == item.TeamId, ct);
if (team is null || !permissions.Has(Capability.WorkTasks, ScopeRef.Team(item.TeamId), ScopeRef.Org(team.OrganizationId)))
{
return Results.Forbid();
}
foreach (var child in await db.WorkItems.Where(w => w.ParentId == id).ToListAsync(ct))
{
child.ClearParent();
}
db.Transitions.RemoveRange(await db.Transitions.Where(t => t.WorkItemId == id).ToListAsync(ct));
db.WorkItems.Remove(item);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("task.deleted", "WorkItem", id, user.MemberId, item.Title), ct);
return Results.NoContent();
}
private static async Task<IResult> GetBoard( private static async Task<IResult> GetBoard(
Guid teamId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct) Guid teamId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
{ {
@@ -354,6 +541,76 @@ internal static class OrgBoardEndpoints
return Results.Ok(ToResponse(item)); return Results.Ok(ToResponse(item));
} }
// Autopilot for a big task: dispatch a run for every outstanding child in one shot, fanning them
// across the team's configured AI seats (round-robin), or all to one seat if specified. Children
// that already carry an artifact or are done are skipped, so re-running only picks up what's left.
// The agents still act per their own autonomy — gated ones land in review; this just starts them all.
private static async Task<IResult> RunAllChildren(
Guid id, RunAllRequest request, ICurrentUser user, IPermissionService permissions,
IAgentDispatcher dispatcher, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var parent = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == id, ct);
if (parent is null)
{
return Results.NotFound("Task not found.");
}
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == parent.TeamId, ct);
if (team is null)
{
return Results.NotFound("Team not found.");
}
if (!permissions.Has(Capability.WorkTasks, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
{
return Results.Forbid();
}
// Outstanding children: not done and no delivered artifact yet.
var children = await db.WorkItems
.Where(w => w.ParentId == id
&& w.Status != WorkItemStatus.Done
&& (w.Description == null || w.Description == ""))
.OrderBy(w => w.CreatedAtUtc)
.ToListAsync(ct);
if (children.Count == 0)
{
return Results.Ok(new RunAllResponse(0, Array.Empty<RunDispatch>()));
}
// The pool of AI seats to run them on: configured AI seats on the team (optionally just one).
var seats = await db.Seats
.Where(s => s.TeamId == team.Id && s.State == SeatState.Ai && s.AgentId != null)
.OrderBy(s => s.CreatedAtUtc)
.ToListAsync(ct);
if (request.SeatId is { } chosen)
{
seats = seats.Where(s => s.Id == chosen).ToList();
}
if (seats.Count == 0)
{
return Results.BadRequest("No configured AI seat on this team to run the tasks.");
}
var now = clock.GetUtcNow();
var dispatched = new List<RunDispatch>(children.Count);
for (var i = 0; i < children.Count; i++)
{
var seat = seats[i % seats.Count];
var runId = await dispatcher.DispatchAsync(seat.Id, children[i].Id, ct);
if (children[i].Status == WorkItemStatus.Backlog)
{
children[i].MoveTo(WorkItemStatus.InProgress, now);
}
dispatched.Add(new RunDispatch(children[i].Id, children[i].Title, seat.Id, runId));
}
await db.SaveChangesAsync(ct);
return Results.Ok(new RunAllResponse(dispatched.Count, dispatched));
}
private static async Task<IResult> Cartable(ICurrentUser user, OrgBoardDbContext db, CancellationToken ct) private static async Task<IResult> Cartable(ICurrentUser user, OrgBoardDbContext db, CancellationToken ct)
{ {
var memberId = user.MemberId; var memberId = user.MemberId;
@@ -0,0 +1,598 @@
// <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 TeamUp.Modules.OrgBoard.Persistence;
#nullable disable
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
{
[DbContext(typeof(OrgBoardDbContext))]
[Migration("20260617041239_AddChangeRequests")]
partial class AddChangeRequests
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("orgboard")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Agent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Autonomy")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<List<string>>("Docs")
.IsRequired()
.HasColumnType("text[]");
b.Property<Guid?>("FallbackApiConfigId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<Guid>>("McpServerIds")
.IsRequired()
.HasColumnType("uuid[]");
b.Property<string>("Monogram")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Persona")
.HasColumnType("text");
b.Property<Guid>("SeatId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("SkillKeys")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("SeatId")
.IsUnique();
b.ToTable("agents", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.AgentProfile", 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.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Monogram")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid?>("OrganizationId")
.HasColumnType("uuid");
b.Property<int>("Origin")
.HasColumnType("integer");
b.Property<string>("ProfileKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("RecommendedAutonomy")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.PrimitiveCollection<List<string>>("Roles")
.IsRequired()
.HasColumnType("text[]");
b.PrimitiveCollection<List<string>>("SkillKeys")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Summary")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
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("OrganizationId", "ProfileKey", "Version")
.IsUnique();
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "ProfileKey", "Version"), false);
b.ToTable("agent_profiles", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ChangeRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal?>("Amount")
.HasPrecision(12, 2)
.HasColumnType("numeric(12,2)");
b.Property<DateTimeOffset?>("ApprovedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<decimal?>("EstimateHours")
.HasPrecision(9, 2)
.HasColumnType("numeric(9,2)");
b.Property<DateTimeOffset?>("EstimatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LiveAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("PaidAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.ToTable("change_requests", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ChangeRequestStep", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChangeRequestId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DependsOnStepId")
.HasColumnType("uuid");
b.Property<Guid?>("DivisionId")
.HasColumnType("uuid");
b.Property<decimal>("EstimateHours")
.HasPrecision(9, 2)
.HasColumnType("numeric(9,2)");
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChangeRequestId");
b.ToTable("change_request_steps", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Division", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.ToTable("divisions", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.ToTable("organizations", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Product", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DivisionId")
.HasColumnType("uuid");
b.Property<string>("Identity")
.HasColumnType("text");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("DivisionId");
b.HasIndex("OrganizationId");
b.ToTable("products", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ProductProfile", 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.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
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>("ProfileKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Summary")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
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("OrganizationId", "ProfileKey", "Version")
.IsUnique();
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "ProfileKey", "Version"), false);
b.ToTable("product_profiles", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AgentId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("MemberId")
.HasColumnType("uuid");
b.Property<string>("RoleName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TeamId");
b.ToTable("seats", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid?>("ProductId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("ProductId");
b.ToTable("teams", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<string>("AssigneeKind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedByMemberId")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("TeamId");
b.HasIndex("AssigneeKind", "AssigneeId");
b.ToTable("work_items", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItemTransition", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("ActorMemberId")
.HasColumnType("uuid");
b.Property<string>("FromStatus")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("OccurredAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.Property<string>("ToStatus")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("WorkItemId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TeamId");
b.HasIndex("WorkItemId");
b.ToTable("work_item_transitions", "orgboard");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,86 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddChangeRequests : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "change_request_steps",
schema: "orgboard",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChangeRequestId = table.Column<Guid>(type: "uuid", nullable: false),
DivisionId = table.Column<Guid>(type: "uuid", nullable: true),
Title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
EstimateHours = table.Column<decimal>(type: "numeric(9,2)", precision: 9, scale: 2, nullable: false),
Status = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
DependsOnStepId = table.Column<Guid>(type: "uuid", nullable: true),
Order = table.Column<int>(type: "integer", nullable: false),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_change_request_steps", x => x.Id);
});
migrationBuilder.CreateTable(
name: "change_requests",
schema: "orgboard",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
CustomerName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
Description = table.Column<string>(type: "text", nullable: true),
Status = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
EstimateHours = table.Column<decimal>(type: "numeric(9,2)", precision: 9, scale: 2, nullable: true),
Amount = table.Column<decimal>(type: "numeric(12,2)", precision: 12, scale: 2, nullable: true),
Currency = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
EstimatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ApprovedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
PaidAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LiveAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_change_requests", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_change_request_steps_ChangeRequestId",
schema: "orgboard",
table: "change_request_steps",
column: "ChangeRequestId");
migrationBuilder.CreateIndex(
name: "IX_change_requests_OrganizationId",
schema: "orgboard",
table: "change_requests",
column: "OrganizationId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "change_request_steps",
schema: "orgboard");
migrationBuilder.DropTable(
name: "change_requests",
schema: "orgboard");
}
}
}
@@ -170,6 +170,116 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
b.ToTable("agent_profiles", "orgboard"); b.ToTable("agent_profiles", "orgboard");
}); });
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ChangeRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal?>("Amount")
.HasPrecision(12, 2)
.HasColumnType("numeric(12,2)");
b.Property<DateTimeOffset?>("ApprovedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<decimal?>("EstimateHours")
.HasPrecision(9, 2)
.HasColumnType("numeric(9,2)");
b.Property<DateTimeOffset?>("EstimatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LiveAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("PaidAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.ToTable("change_requests", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.ChangeRequestStep", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChangeRequestId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DependsOnStepId")
.HasColumnType("uuid");
b.Property<Guid?>("DivisionId")
.HasColumnType("uuid");
b.Property<decimal>("EstimateHours")
.HasPrecision(9, 2)
.HasColumnType("numeric(9,2)");
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChangeRequestId");
b.ToTable("change_request_steps", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Division", b => modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Division", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -17,6 +17,8 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
public DbSet<ProductProfile> ProductProfiles => Set<ProductProfile>(); public DbSet<ProductProfile> ProductProfiles => Set<ProductProfile>();
public DbSet<WorkItem> WorkItems => Set<WorkItem>(); public DbSet<WorkItem> WorkItems => Set<WorkItem>();
public DbSet<WorkItemTransition> Transitions => Set<WorkItemTransition>(); public DbSet<WorkItemTransition> Transitions => Set<WorkItemTransition>();
public DbSet<ChangeRequest> ChangeRequests => Set<ChangeRequest>();
public DbSet<ChangeRequestStep> ChangeRequestSteps => Set<ChangeRequestStep>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -133,5 +135,28 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
transition.HasIndex(t => t.WorkItemId); transition.HasIndex(t => t.WorkItemId);
transition.HasIndex(t => t.TeamId); transition.HasIndex(t => t.TeamId);
}); });
modelBuilder.Entity<ChangeRequest>(cr =>
{
cr.ToTable("change_requests");
cr.HasKey(c => c.Id);
cr.Property(c => c.CustomerName).HasMaxLength(200).IsRequired();
cr.Property(c => c.Title).HasMaxLength(300).IsRequired();
cr.Property(c => c.Status).HasConversion<string>().HasMaxLength(16);
cr.Property(c => c.EstimateHours).HasPrecision(9, 2);
cr.Property(c => c.Amount).HasPrecision(12, 2);
cr.Property(c => c.Currency).HasMaxLength(8).IsRequired();
cr.HasIndex(c => c.OrganizationId);
});
modelBuilder.Entity<ChangeRequestStep>(step =>
{
step.ToTable("change_request_steps");
step.HasKey(s => s.Id);
step.Property(s => s.Title).HasMaxLength(300).IsRequired();
step.Property(s => s.Status).HasConversion<string>().HasMaxLength(16);
step.Property(s => s.EstimateHours).HasPrecision(9, 2);
step.HasIndex(s => s.ChangeRequestId);
});
} }
} }
@@ -35,7 +35,14 @@ internal sealed class BoardWriter(OrgBoardDbContext db, TimeProvider clock) : IB
var item = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == workItemId, cancellationToken) var item = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == workItemId, cancellationToken)
?? throw new InvalidOperationException($"Work item {workItemId} not found."); ?? throw new InvalidOperationException($"Work item {workItemId} not found.");
item.AttachArtifact(content, clock.GetUtcNow()); var now = clock.GetUtcNow();
item.AttachArtifact(content, now);
// Surface the delivered artifact on the board: move it into review (unless already done).
if (item.Status is WorkItemStatus.Backlog or WorkItemStatus.InProgress)
{
item.MoveTo(WorkItemStatus.InReview, now);
}
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
} }
} }