Slice 5: graduation per §13

The §13.3 transactional sequence flips a super-draft to active —
five steps with paired undoes, an in-process orchestrator fed by
an asyncio.Queue, the §17 SSE endpoint streaming step transitions
to the dialog. Each step is a new bot primitive that logs an
`actions` row, bracketed by `graduate_start` / `graduate_complete`
for the linkable audit sequence. Rollback runs the undoes in
reverse from the last completed step; merge_pr has no undo by
design per §13.5.

The §9.8 precondition gate is enforced server-side at the top of
POST /graduate so the §13.3 rollback complexity does not grow.
The §13.4 chat migration is a database semantic no-op — the
(slug, branch_name='main') threads keep their identity, only the
interpretation changes. The §9.8 pre-graduation history surfaces
via a new _is_meta_target(rfc, branch) dispatch helper and lands
as pre_graduation_history on /main.

§13.1 claim flow landed alongside since it's the prerequisite for
non-admin graduation — bot.open_claim_pr plus broadening
api_prs._require_pr to accept meta_claim.

45/45 tests green; ten new integration tests cover the validator,
the §9.8 precondition refusal, happy path with audit verification,
mid-sequence rollback at steps 2 and 3, concurrent refusal,
chat-survives-without-data-movement, pre-graduation history, and
the §13.1 claim PR cycle.

SPEC.md §19.1 rewritten for Slice 6 (notifications); §19.2 grew
four candidates surfaced during the slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 21:52:29 -07:00
parent 4565a6cb95
commit 1b0968a9a2
14 changed files with 2872 additions and 172 deletions
+118
View File
@@ -1021,3 +1021,121 @@
}
.pr-review-quote { font-size: 11px; color: #6b7280; margin-bottom: 6px; }
.pr-review-quote pre { background: #fff; padding: 4px 8px; border-radius: 4px; margin: 4px 0 0 0; }
/* ── Slice 5: §13 graduation dialog ──────────────────────────────────── */
.modal-wide { width: min(720px, 92vw); }
.modal-intro { margin: 0 0 16px 0; font-size: 13px; color: #4b5563; line-height: 1.55; }
.form-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
.form-row label { font-weight: 600; font-size: 12px; color: #1a1a1a; }
.form-row input, .form-row textarea {
font: inherit; padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 4px;
}
.field-help { font-size: 11px; color: #6b7280; margin: 2px 0 0 0; }
.field-error { font-size: 12px; color: #b91c1c; margin: 4px 0 0 0; }
.owner-list {
display: flex; flex-wrap: wrap; gap: 6px; min-height: 28px;
padding: 4px 0;
}
.owner-empty { font-size: 12px; color: #6b7280; font-style: italic; }
.owner-chip {
display: inline-flex; align-items: center; gap: 4px;
background: #eef2ff; color: #3730a3; padding: 2px 8px; border-radius: 99px;
font-size: 12px;
}
.owner-chip-x {
background: none; border: none; cursor: pointer; color: #6b7280;
font-size: 14px; line-height: 1; padding: 0 2px;
}
.owner-chip-x:hover { color: #b91c1c; }
.owner-picker { display: flex; gap: 6px; margin-top: 6px; }
.owner-picker input { flex: 1; }
.precondition-block { margin-top: 12px; padding: 10px; background: #fef2f2; border-radius: 6px; border: 1px solid #fecaca; }
.precondition-toggle {
background: none; border: none; cursor: pointer; color: #b91c1c; font-weight: 600;
font-size: 13px; padding: 0;
}
.precondition-popover { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; }
.precondition-row {
display: flex; justify-content: space-between; align-items: center;
background: #fff; padding: 8px 10px; border-radius: 4px; border: 1px solid #f3f4f6;
}
.precondition-row-meta { font-size: 11px; color: #6b7280; }
.precondition-row-actions a { color: #5b5bd6; }
.precondition-help { font-size: 11px; color: #6b7280; margin: 4px 0 0 0; font-style: italic; }
.btn-graduate { margin-left: 6px; }
.step-stack { display: flex; flex-direction: column; gap: 6px; margin: 12px 0; }
.step-row {
display: grid; grid-template-columns: 24px 1fr auto; gap: 10px; align-items: center;
padding: 8px 10px; border-radius: 6px; background: #f9fafb; border: 1px solid #f3f4f6;
}
.step-row.step-running { background: #eff6ff; border-color: #bfdbfe; }
.step-row.step-done { background: #f0fdf4; border-color: #bbf7d0; }
.step-row.step-failed { background: #fef2f2; border-color: #fecaca; }
.step-row.step-not-reached { opacity: 0.45; }
.step-marker {
display: inline-block; width: 16px; height: 16px; border-radius: 50%;
background: #d1d5db;
}
.step-marker-pending { background: #d1d5db; }
.step-marker-running { background: #3b82f6; animation: pulse 1s ease-in-out infinite; }
.step-marker-done { background: #10b981; }
.step-marker-failed { background: #ef4444; }
.step-marker-not-reached { background: #e5e7eb; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.step-label { font-weight: 600; font-size: 13px; color: #1a1a1a; }
.step-detail { font-size: 11px; color: #6b7280; margin-top: 2px; }
.step-status-pill {
font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 3px;
text-transform: uppercase; letter-spacing: 0.04em;
}
.pill-pending { background: #e5e7eb; color: #4b5563; }
.pill-running { background: #3b82f6; color: #fff; }
.pill-done { background: #10b981; color: #fff; }
.pill-failed { background: #ef4444; color: #fff; }
.pill-not-reached { background: #e5e7eb; color: #9ca3af; }
.rollback-divider {
margin: 10px 0 6px 0; font-size: 11px; text-transform: uppercase;
letter-spacing: 0.06em; color: #b91c1c; font-weight: 700;
}
.what-happened {
margin-top: 14px; padding: 12px; background: #fef2f2;
border: 1px solid #fecaca; border-radius: 6px;
}
.what-happened h3 { margin: 0 0 6px 0; font-size: 14px; color: #991b1b; }
.what-happened p { margin: 0 0 6px 0; font-size: 13px; color: #4b5563; line-height: 1.55; }
.graduation-complete {
margin-top: 14px; padding: 12px; background: #f0fdf4;
border: 1px solid #bbf7d0; border-radius: 6px;
}
.graduation-complete h3 { margin: 0 0 6px 0; font-size: 14px; color: #166534; }
.graduation-complete p { margin: 0; font-size: 13px; color: #14532d; line-height: 1.55; }
.modal-progress-note { font-size: 12px; color: #6b7280; }
.modal-error {
padding: 8px 12px; background: #fef2f2; color: #991b1b;
border-top: 1px solid #fecaca; font-size: 12px;
}
.rfc-error-banner {
padding: 8px 12px; background: #fef2f2; color: #991b1b;
border-bottom: 1px solid #fecaca; font-size: 12px;
}
.branch-dropdown-section {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
color: #6b7280; padding: 8px 10px 4px 10px; font-weight: 700;
}
.branch-dropdown-item.pre-graduation {
font-style: italic; color: #4b5563;
}
.branch-dropdown-item.pre-graduation .branch-meta {
font-size: 10px; color: #9ca3af; margin-left: auto;
}
+48
View File
@@ -221,6 +221,54 @@ export async function editMetadata(slug, { title, tags, prDescription }) {
return jsonOrThrow(res)
}
// ── Slice 5: §13 graduation + §13.1 claim ────────────────────────────────
export async function claimOwnership(slug) {
const res = await fetch(`/api/rfcs/${slug}/claim`, { method: 'POST' })
return jsonOrThrow(res)
}
export async function listBlockingPRs(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/blocking-prs`))
}
export async function graduateCheck(slug, { id, repo }) {
const params = new URLSearchParams()
if (id != null) params.set('id', id)
if (repo != null) params.set('repo', repo)
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/graduate/check?${params}`))
}
export async function startGraduation(slug, { rfcId, repoName, owners }) {
const res = await fetch(`/api/rfcs/${slug}/graduate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rfc_id: rfcId, repo_name: repoName, owners }),
})
return jsonOrThrow(res)
}
// Open an EventSource on the §13.3 progress stream. Returns the
// EventSource so the caller can close() on dialog dismiss. Calls
// onUpdate with the parsed state payload for every event.
export function openGraduationProgress(slug, { onUpdate, onDone, onError }) {
const es = new EventSource(`/api/rfcs/${slug}/graduate/progress`)
const handle = (e) => {
try {
const payload = JSON.parse(e.data)
onUpdate?.(payload, e.type)
if (e.type === 'done' && payload?.finished) onDone?.(payload)
} catch (err) {
onError?.(err)
}
}
for (const name of ['snapshot', 'step', 'rollback_step', 'completed', 'rolled_back', 'done']) {
es.addEventListener(name, handle)
}
es.onerror = (e) => { onError?.(e); es.close() }
return es
}
// ── Slice 3: the §10 PR flow ─────────────────────────────────────────────
export async function draftPRText(slug, branch) {
+357
View File
@@ -0,0 +1,357 @@
// GraduateDialog.jsx — the §13.2 Graduate dialog and the §13.3 step stack.
//
// Renders three editable fields (integer ID, repo name, initial owners)
// with debounced server-side validation per §13.2 and a precondition
// popover backed by /blocking-prs for the §9.8 open-body-edit-PR gate.
//
// On confirm, opens the §13.3 SSE stream and renders the five named
// steps with per-step states. On failure, the rollback step's events
// append to the stack and a "What happened" panel renders below until
// the admin dismisses it.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
graduateCheck,
listBlockingPRs,
openGraduationProgress,
startGraduation,
} from '../api'
const CHECK_DEBOUNCE_MS = 250
const STEP_KEY_ORDER = ['create_repo', 'seed_files', 'open_pr', 'merge_pr', 'refresh_cache']
export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
// Suggest defaults from the catalog.
const suggestedId = useMemo(() => suggestNextRfcId(entry?.allKnownIds || []), [entry])
const [rfcId, setRfcId] = useState(suggestedId)
const [repoName, setRepoName] = useState(`rfc-${stripPrefix(suggestedId)}-${slug}`)
const [owners, setOwners] = useState(entry?.owners?.length ? entry.owners : [])
const [newOwner, setNewOwner] = useState('')
const [checkResult, setCheckResult] = useState(null)
const [blockingPRs, setBlockingPRs] = useState([])
const [precondPopover, setPrecondPopover] = useState(false)
const [phase, setPhase] = useState('idle') // idle | running | done | rolled_back | error
const [streamState, setStreamState] = useState(null)
const [submitError, setSubmitError] = useState(null)
const esRef = useRef(null)
// Initial blocking-PRs probe + ongoing /check polling.
useEffect(() => {
listBlockingPRs(slug).then(({ items }) => setBlockingPRs(items || [])).catch(() => {})
}, [slug])
useEffect(() => {
const t = setTimeout(() => {
graduateCheck(slug, { id: rfcId, repo: repoName })
.then(setCheckResult)
.catch(() => {})
}, CHECK_DEBOUNCE_MS)
return () => clearTimeout(t)
}, [slug, rfcId, repoName])
useEffect(() => () => { esRef.current?.close() }, [])
const idError = checkResult?.id?.error || null
const repoError = checkResult?.repo?.error || null
const ownersOk = owners.length > 0
const ownersError = ownersOk ? null : 'Add at least one initial owner'
const blockingError = blockingPRs.length > 0
? `${blockingPRs.length} open body-edit PR${blockingPRs.length === 1 ? '' : 's'} blocking graduation`
: null
// First-blocker tooltip text per §13.2.
const firstBlocker = idError || repoError || ownersError || blockingError
const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && checkResult?.repo?.ok
const handleAddOwner = useCallback(() => {
const v = newOwner.trim().toLowerCase()
if (!v || owners.includes(v)) return
setOwners(prev => [...prev, v])
setNewOwner('')
}, [newOwner, owners])
const handleRemoveOwner = useCallback((login) => {
setOwners(prev => prev.filter(o => o !== login))
}, [])
const handleConfirm = useCallback(async () => {
setSubmitError(null)
setPhase('running')
try {
await startGraduation(slug, { rfcId, repoName, owners })
} catch (err) {
setPhase('idle')
setSubmitError(err.message)
return
}
esRef.current = openGraduationProgress(slug, {
onUpdate: (payload) => {
setStreamState(payload)
if (payload?.finished) {
if (payload.succeeded) {
setPhase('done')
// Short hold per §13.3, then dismiss.
setTimeout(() => onCompleted?.(payload), 1500)
} else {
setPhase('rolled_back')
}
}
},
onError: () => {
setSubmitError('Lost connection to the graduation stream — refresh to see current state.')
setPhase('error')
},
})
}, [slug, rfcId, repoName, owners, onCompleted])
// ----- Render -----
const showStack = phase !== 'idle' && (streamState?.steps?.length || 0) > 0
return (
<div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget && phase === 'idle') onClose?.() }}>
<div className="modal modal-wide">
<div className="modal-header">
<h2>Graduate `{slug}` to active</h2>
{phase === 'idle' && <button className="modal-close" onClick={onClose}>×</button>}
</div>
{!showStack && (
<div className="modal-body">
<p className="modal-intro">
§13: graduate the super-draft to its own repo. The meta-repo entry
becomes frontmatter-only; the canonical body moves to `RFC.md` in
the new repo. The sequence runs as five transactional steps with
rollback per §13.3.
</p>
<div className="form-row">
<label>Integer ID</label>
<input
type="text"
value={rfcId}
onChange={(e) => setRfcId(e.target.value.trim())}
placeholder="RFC-NNNN"
disabled={phase !== 'idle'}
/>
<p className="field-help">Pre-filled as the next free integer; editable to reserve gaps.</p>
{idError && <p className="field-error">{idError}</p>}
</div>
<div className="form-row">
<label>Repo name</label>
<input
type="text"
value={repoName}
onChange={(e) => setRepoName(e.target.value.trim())}
placeholder="rfc-NNNN-slug"
disabled={phase !== 'idle'}
/>
<p className="field-help">Becomes `&lt;org&gt;/{repoName || 'rfc-…'}` on Gitea.</p>
{repoError && <p className="field-error">{repoError}</p>}
</div>
<div className="form-row">
<label>Initial owners</label>
<div className="owner-list">
{owners.length === 0 && <span className="owner-empty">No owners yet add at least one.</span>}
{owners.map(o => (
<span key={o} className="owner-chip">
{o}
<button
type="button"
className="owner-chip-x"
onClick={() => handleRemoveOwner(o)}
disabled={phase !== 'idle'}
aria-label={`Remove ${o}`}
>×</button>
</span>
))}
</div>
<div className="owner-picker">
<input
type="text"
value={newOwner}
onChange={(e) => setNewOwner(e.target.value)}
placeholder="Gitea login"
disabled={phase !== 'idle'}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddOwner() }}
/>
<button
type="button"
className="btn-secondary"
onClick={handleAddOwner}
disabled={phase !== 'idle' || !newOwner.trim()}
>Add</button>
</div>
{ownersError && <p className="field-error">{ownersError}</p>}
</div>
{blockingPRs.length > 0 && (
<div className="precondition-block">
<button
type="button"
className="precondition-toggle"
onClick={() => setPrecondPopover(p => !p)}
>
{blockingPRs.length} open body-edit PR{blockingPRs.length === 1 ? '' : 's'} blocking graduation
&nbsp;{precondPopover ? '▾' : '▸'}
</button>
{precondPopover && (
<div className="precondition-popover">
{blockingPRs.map(pr => (
<div key={pr.pr_number} className="precondition-row">
<div className="precondition-row-main">
<strong>PR #{pr.pr_number}</strong> {pr.title || '(no title)'}
<div className="precondition-row-meta">
{pr.author ? `by @${pr.author}` : ''}
{pr.last_activity_at ? ` · ${pr.last_activity_at.slice(0, 10)}` : ''}
</div>
</div>
<div className="precondition-row-actions">
<a
className="btn-link"
href={`/rfc/${slug}/pr/${pr.pr_number}`}
target="_blank"
rel="noreferrer"
>Open </a>
</div>
</div>
))}
<p className="precondition-help">
§9.8: open body-edit PRs would attempt to re-introduce a
body to a frontmatter-only entry after step 3. Resolve
them (merge or withdraw) and re-open this dialog.
</p>
</div>
)}
</div>
)}
</div>
)}
{showStack && (
<div className="modal-body">
<StepStack
steps={streamState?.steps || []}
rollbackSteps={streamState?.rollback_steps || []}
/>
{phase === 'rolled_back' && (
<div className="what-happened">
<h3>What happened</h3>
<p>
The graduation could not complete. The app rolled back the
steps that had already run; nothing was left half-applied on
Gitea. Error: <code>{streamState?.error || 'unknown'}</code>.
</p>
<p>
Read the failure detail next to the red step above. Resolve
the underlying cause (a repo-name collision, a network flake,
a concurrent PR landing on `rfcs/{slug}.md`) and try again.
</p>
</div>
)}
{phase === 'done' && (
<div className="graduation-complete">
<h3>Graduation complete</h3>
<p>
`{slug}` is now active as <strong>{streamState?.rfc_id}</strong>{' '}
at <code>{streamState?.repo_full}</code>. The catalog and the
RFC view reflect the new state.
</p>
</div>
)}
</div>
)}
<div className="modal-actions">
{phase === 'idle' && (
<>
<button className="btn-secondary" onClick={onClose}>Cancel</button>
<button
className="btn-primary"
onClick={handleConfirm}
disabled={!canSubmit}
title={canSubmit ? '' : firstBlocker || ''}
>
Graduate to RFC repo
</button>
</>
)}
{phase === 'running' && (
<span className="modal-progress-note">Running graduation sequence</span>
)}
{(phase === 'rolled_back' || phase === 'error') && (
<button className="btn-secondary" onClick={onClose}>Close</button>
)}
{phase === 'done' && (
<button className="btn-primary" onClick={() => onCompleted?.(streamState)}>
View the new RFC
</button>
)}
</div>
{submitError && phase !== 'rolled_back' && (
<div className="modal-error">Error: {submitError}</div>
)}
</div>
</div>
)
}
function StepStack({ steps, rollbackSteps }) {
return (
<div className="step-stack">
{steps.map(s => <StepRow key={s.key} step={s} />)}
{rollbackSteps.length > 0 && (
<div className="rollback-divider">Rollback</div>
)}
{rollbackSteps.map(s => <StepRow key={s.key} step={s} />)}
</div>
)
}
function StepRow({ step }) {
return (
<div className={`step-row step-${step.status}`}>
<span className={`step-marker step-marker-${step.status}`} />
<div className="step-text">
<div className="step-label">{step.label}</div>
{step.detail && <div className="step-detail">{step.detail}</div>}
</div>
<div className={`step-status-pill pill-${step.status}`}>{labelFor(step.status)}</div>
</div>
)
}
function labelFor(status) {
switch (status) {
case 'pending': return 'pending'
case 'running': return 'running'
case 'done': return 'done'
case 'failed': return 'failed'
case 'not-reached': return 'not reached'
default: return status
}
}
function suggestNextRfcId(existing) {
const used = new Set()
for (const id of existing) {
const m = /^RFC-(\d+)$/.exec(id || '')
if (m) used.add(Number(m[1]))
}
const next = used.size === 0 ? 1 : (Math.max(...used) + 1)
return `RFC-${String(next).padStart(4, '0')}`
}
function stripPrefix(rfcId) {
return rfcId?.startsWith('RFC-') ? rfcId.slice(4) : rfcId
}
+75
View File
@@ -40,6 +40,8 @@ import ChatPanel from './ChatPanel.jsx'
import ChangePanel from './ChangePanel.jsx'
import DiffView from './DiffView.jsx'
import PRModal from './PRModal.jsx'
import GraduateDialog from './GraduateDialog.jsx'
import { claimOwnership } from '../api'
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
const MANUAL_DEBOUNCE_MS = 800
@@ -108,6 +110,8 @@ export default function RFCView({ viewer }) {
// metadata pane, and the start-contributing dispatch target.
const isSuperDraft = entry?.state === 'super-draft'
const [showMetadataPane, setShowMetadataPane] = useState(false)
const [showGraduateDialog, setShowGraduateDialog] = useState(false)
const [claimError, setClaimError] = useState(null)
// Load main view + branch view whenever slug/branch changes.
useEffect(() => {
@@ -538,8 +542,42 @@ export default function RFCView({ viewer }) {
Metadata
</button>
)}
{isSuperDraft && viewer && entry?.owners && !entry.owners.includes(viewer.gitea_login) && (
<button
type="button"
className="btn-link"
onClick={async () => {
setClaimError(null)
try {
const result = await claimOwnership(slug)
if (result?.noop) return
if (result?.pr_number) {
navigate(`/rfc/${slug}/pr/${result.pr_number}`)
}
} catch (err) {
setClaimError(err.message)
}
}}
title="§13.1 — open a claim PR adding you to the entry's owners"
>
Claim ownership
</button>
)}
{isSuperDraft && viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && (
<button
type="button"
className="btn-primary btn-graduate"
onClick={() => setShowGraduateDialog(true)}
title="§13 — graduate this super-draft to a per-RFC repo"
>
Graduate to RFC repo
</button>
)}
</div>
</div>
{claimError && (
<div className="rfc-error-banner">Claim failed: {claimError}</div>
)}
{/* Two columns: editor + chat */}
<div className="rfc-body">
@@ -693,6 +731,20 @@ export default function RFCView({ viewer }) {
/>
)}
{showGraduateDialog && (
<GraduateDialog
slug={slug}
entry={entry}
onClose={() => setShowGraduateDialog(false)}
onCompleted={() => {
setShowGraduateDialog(false)
// The catalog row and the RFC view now reflect `active`.
getRFC(slug).then(setEntry).catch(() => {})
getRFCMain(slug).then(setMainView).catch(() => {})
}}
/>
)}
{showMetadataPane && (
<MetadataPaneModal
slug={slug}
@@ -776,6 +828,7 @@ function BranchDropdown({ current, mainView, isSuperDraft, onPick }) {
// RFCs it is literally `main` per §8.1.
const mainLabel = isSuperDraft ? 'canonical body' : 'main'
const items = [{ name: 'main', label: mainLabel }, ...(mainView?.branches || [])]
const preGrad = mainView?.pre_graduation_history || []
const currentLabel = current === 'main' ? mainLabel : current
return (
<div className="branch-dropdown">
@@ -804,6 +857,28 @@ function BranchDropdown({ current, mainView, isSuperDraft, onPick }) {
)}
</button>
))}
{preGrad.length > 0 && (
<>
<div className="branch-dropdown-section">
Pre-graduation history ({preGrad.length})
</div>
{preGrad.map(b => (
<button
key={b.branch_name}
type="button"
className={`branch-dropdown-item pre-graduation ${b.branch_name === current ? 'active' : ''}`}
onClick={() => { setOpen(false); onPick(b.branch_name) }}
title="§9.8: meta-repo edit branch from before graduation — read-only"
>
<span className="branch-name">{b.branch_name}</span>
<span className="branch-meta">
{b.thread_count} thread{b.thread_count === 1 ? '' : 's'}
{b.change_count ? ` · ${b.change_count} change${b.change_count === 1 ? '' : 's'}` : ''}
</span>
</button>
))}
</>
)}
</div>
)}
</div>