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
+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) {