Overview as decision control center: structured questions with inline choice/text answers that re-trigger the run - #177
Open
danthebaker wants to merge 1 commit into
Open
Overview as decision control center: structured questions with inline choice/text answers that re-trigger the run#177danthebaker wants to merge 1 commit into
danthebaker wants to merge 1 commit into
Conversation
Comment on lines
+525
to
+651
| router.post('/decisions/:id/answer', async (req, res) => { | ||
| if (!rateLimit(`se-admin:decision-answer:${clientIp(req)}`, 30, 60_000)) { | ||
| return res.status(429).json({ error: { code: 'rate_limited', message: 'Too many requests' } }); | ||
| } | ||
| const id = req.params.id; | ||
| if (!UUID.test(id)) return res.status(400).json({ error: 'bad id' }); | ||
| const { data: decision } = await supabase.from('se_decisions').select('*').eq('id', id).maybeSingle(); | ||
| if (!decision) return res.status(404).json({ error: 'not found' }); | ||
| if (decision.status !== 'pending') { | ||
| return res.status(409).json({ error: { code: 'already_answered', message: `decision is ${decision.status}` } }); | ||
| } | ||
| const { data: run } = await supabase.from('se_runs') | ||
| .select('id, site_id, project_id, status, kind, archived_at, current_phase, error, repo_owner, repo_name, issue_number') | ||
| .eq('id', decision.run_id).maybeSingle(); | ||
| if (!run) return res.status(404).json({ error: 'run not found' }); | ||
| if (await denyIfNotApprover(req, res, run)) return; // Advance action | ||
|
|
||
| // ── Validate the answer shape against the decision's kind ────────────────────────────── | ||
| let optionId: string | null = null; | ||
| let text = ''; | ||
| if (decision.kind === 'choice') { | ||
| optionId = typeof req.body?.option_id === 'string' ? req.body.option_id : null; | ||
| const valid = (decision.options ?? []).some((o: any) => o?.id === optionId); | ||
| if (!optionId || !valid) return res.status(400).json({ error: { code: 'invalid_option', message: 'option_id must match one of the decision options' } }); | ||
| text = sanitizeAnswerText(req.body?.text); | ||
| } else { | ||
| text = sanitizeAnswerText(req.body?.text); | ||
| if (!text) return res.status(400).json({ error: { code: 'empty_answer', message: 'text is required' } }); | ||
| } | ||
|
|
||
| // A true architecture decision if the run is still at (or past) the architecture gate — the run's | ||
| // status, not decision.kind alone, disambiguates this from a coincidentally-shaped choice decision. | ||
| const isArchitecture = ARCH_STATES.includes(run.status) && ARCH_ANSWER_OPTIONS.has(optionId ?? ''); | ||
| if (isArchitecture && (optionId === 'request_changes' || optionId === 'reject') && !text) { | ||
| return res.status(400).json({ error: { code: 'empty_answer', message: 'text is required for this option' } }); | ||
| } | ||
|
|
||
| // Non-architecture origin: re-derive the same classification the resume route uses, and reject | ||
| // outright if it resolves to config_blocked — that class is not agent-discussable. | ||
| let originKind: string | null = null; | ||
| let gateDetail: any = null; | ||
| if (!isArchitecture) { | ||
| const { data: prs } = await supabase.from('se_run_prs').select('state').eq('run_id', run.id); | ||
| originKind = classifyDecision(run, prs ?? []); | ||
| if (originKind === 'config_blocked' || originKind == null) { | ||
| return res.status(400).json({ error: { code: 'not_answerable', message: 'This block is a configuration/credential issue — resolve it in Setup, then resume the run.' } }); | ||
| } | ||
| if (originKind === 'review_blocked') { | ||
| const { data: gate } = await supabase.from('se_gates') | ||
| .select('detail').eq('run_id', run.id).eq('gate', 'adversarial_review').order('created_at', { ascending: false }).limit(1).maybeSingle(); | ||
| gateDetail = gate?.detail ?? null; | ||
| } | ||
| } | ||
|
|
||
| const actorId = authorOf(req); | ||
| const answer = decision.kind === 'choice' ? { option_id: optionId, text: text || undefined } : { text }; | ||
| // A distilled review_blocked decision (workers/review.ts's distillDecision) can ALSO be | ||
| // kind:'choice' with custom option ids/labels, not just the fixed architecture options. The | ||
| // resumed agent needs to see WHICH option was picked, not just the optional free-text reason — | ||
| // so build the human-readable summary from the option's label whenever one was selected. | ||
| const chosenLabel = decision.kind === 'choice' ? (decision.options ?? []).find((o: any) => o.id === optionId)?.label ?? optionId : null; | ||
| const answerSummary = chosenLabel ? (text ? `${chosenLabel} — ${text}` : chosenLabel) : text; | ||
|
|
||
| // ── CAS the decision to answered BEFORE acting — a lost race means someone else already | ||
| // answered it, so bail out rather than double-resume the run. ───────────────────────────── | ||
| const { data: racedDecision, error: decisionError } = await supabase.from('se_decisions') | ||
| .update({ status: 'answered', answer, answered_by: actorId, answered_at: new Date().toISOString() }) | ||
| .eq('id', id).eq('status', 'pending') | ||
| .select().single(); | ||
| if (decisionError) return res.status(500).json({ error: 'update failed' }); | ||
| if (!racedDecision) return res.status(409).json({ error: { code: 'already_answered', message: 'decision was already answered' } }); | ||
|
|
||
| let actionResult: any = { ok: true }; | ||
| let auditNote = `Answered decision: "${decision.question}" → `; | ||
| if (isArchitecture) { | ||
| if (optionId === 'approve') { | ||
| if (run.status !== 'architecture_in_review') { | ||
| actionResult = { status: 409, error: { code: 'not_finalized', message: 'Finalize (commit) the proposal before approving.' } }; | ||
| } else { | ||
| actionResult = await approveArchitecture(supabase, null, run, { actorId, enqueueJob }); | ||
| } | ||
| auditNote += 'approved.'; | ||
| } else if (optionId === 'request_changes') { | ||
| actionResult = await resumeRunForDecision(supabase, null, run, 'architecture', { | ||
| actorId, enqueueJob, note: `Architecture changes requested by admin: ${text}`, | ||
| }); | ||
| auditNote += `requested changes — ${text}`; | ||
| } else { | ||
| const { data: raced } = await supabase.from('se_runs') | ||
| .update({ status: 'blocked', error: `architecture proposal rejected: ${text}`, acting_user_id: actorId }) | ||
| .eq('id', run.id).eq('status', run.status) | ||
| .select('id'); | ||
| actionResult = (!raced || raced.length === 0) | ||
| ? { status: 409, error: { code: 'state_changed', message: 'Run state changed — refresh and retry if still needed.' } } | ||
| : { ok: true }; | ||
| auditNote += `rejected — ${text}`; | ||
| } | ||
| } else if (originKind === 'review_blocked') { | ||
| actionResult = await resumeRunForDecision(supabase, null, run, 'spec', { | ||
| actorId, enqueueJob, extraJobData: { objections: gateDetail?.objections ?? [] }, | ||
| note: `Answered by admin: ${answerSummary}`, | ||
| }); | ||
| auditNote += answerSummary; | ||
| } else { | ||
| // pr_closed_partial (the only remaining non-architecture, non-config_blocked DecisionKind). | ||
| actionResult = await resumeRunForDecision(supabase, null, run, 'revise', { | ||
| actorId, enqueueJob, note: `Answered by admin: ${answerSummary}`, | ||
| }); | ||
| auditNote += answerSummary; | ||
| } | ||
|
|
||
| if (actionResult?.error) { | ||
| // Roll the decision back to pending — the action didn't take effect, so the question is still open. | ||
| await supabase.from('se_decisions').update({ status: 'pending', answer: null, answered_by: null, answered_at: null }).eq('id', id); | ||
| return res.status(actionResult.status ?? 500).json({ error: actionResult.error }); | ||
| } | ||
|
|
||
| if (run.issue_number) { | ||
| try { | ||
| const project = await getProject(supabase, run.project_id); | ||
| if (project?.githubToken) await githubClient(project.githubToken).postComment(run.repo_owner, run.repo_name, run.issue_number, auditNote); | ||
| } catch { /* best-effort */ } | ||
| } | ||
|
|
||
| const { data: freshRun } = await supabase.from('se_runs').select('*').eq('id', run.id).maybeSingle(); | ||
| res.json({ decision: racedDecision, run: freshRun }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements Overview as decision control center: structured questions with inline choice/text answers that re-trigger the run.