From 9403147c9837cdada1a9f24b665b9e87f2685c56 Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 6 Jul 2026 20:45:41 +0200 Subject: [PATCH] feat(replay): infer workflow phase from gate title so WorkflowBar lights the right dot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extract.mjs: inferPhase() maps gate titles ("F1 — …", "Fase 3 completata …") to F1..F8; server.mjs: carries the inferred phase through the replayed ui_request stream. --- tools/replay/extract.mjs | 21 ++++++++++++- tools/replay/server.mjs | 66 ++++++++++++++++++++++++++++++++-------- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/tools/replay/extract.mjs b/tools/replay/extract.mjs index cc673f2e..708290c1 100644 --- a/tools/replay/extract.mjs +++ b/tools/replay/extract.mjs @@ -139,9 +139,28 @@ if (sessionCounts.size > 1) { const answered = calls.filter((c) => resultsByCallId.has(c.callId)); // --- build descriptors (mirrors harness/.pi/extensions/gate/builders.js) ---- + +// Infer the workflow phase (F1..F8) of a gate from its title, so the frontend's +// WorkflowBar lights up the correct dot during replay. Patterns (from real +// session titles): +// "F1 — Chiarimento 1/3: …" → F1 +// "Fase 3 completata — passo alla → F3 (the gate that CLOSES phase 3) +// Fase 4 (Schema linking)?" +// "F6 — CTE 2 approvato: …" → F6 +// Returns null when no phase can be inferred (the frontend keeps the previous +// phase in that case, which is fine for transitional gates). +function inferPhase(title) { + if (typeof title !== "string") return null; + let m = title.match(/Fase\s+(\d+)\s+completata/i); + if (m) return `F${m[1]}`; + m = title.match(/\bF([1-8])\b/); + if (m) return `F${m[1]}`; + return null; +} + function buildDescriptor(name, args, idx) { const id = `u${idx}`; - const phase = "replay"; + const phase = inferPhase(args.title) ?? "replay"; if (name === "reviewer_select") { const opts = parseOptions(args.options).filter((o) => !isReserved(o.label)); const recommendedId = parseOptions(args.options).find((o) => o.recommended)?.id ?? null; diff --git a/tools/replay/server.mjs b/tools/replay/server.mjs index 9b7148dd..2a68fb12 100644 --- a/tools/replay/server.mjs +++ b/tools/replay/server.mjs @@ -226,11 +226,13 @@ const server = createServer(async (req, res) => { Connection: "keep-alive", }); sseClient = res; + console.error(`[sse] nuova connessione SSE, reset cursore a 0`); // A fresh SSE connection restarts the replay from the beginning: this is // what makes the session re-runnable after completion, and what makes a // browser refresh re-walk the gates. (Single-user, single-session.) cursor = 0; req.on("close", () => { + console.error(`[sse] connessione chiusa`); if (sseClient === res) sseClient = null; }); emitCurrentGate(res); @@ -241,6 +243,38 @@ const server = createServer(async (req, res) => { if (method === "POST" && path === `/sessions/${REPLAY_SESSION_ID}/response`) { const body = await readBody(req); const uiResponse = (body?.ui_response ?? {}); + // Exit (the reserved "Exit" control on any gate, OR the "Esci" option on the + // final completed widget): stop the replay and ask the frontend to return to + // the landing. The AppShell has an effect that calls stopSession() on + // system_event { event: "session_exit" }. We do NOT advance the cursor. + const isExit = + uiResponse.control === "exit" || + (typeof uiResponse.id === "string" && uiResponse.id.startsWith(RESTART_ID_PREFIX) && (uiResponse.choices ?? [])[0] === "exit"); + if (isExit) { + console.error("[exit] replay fermato, ritorno alla landing"); + if (sseClient) { + sendSse(sseClient, "system_event", { type: "system_event", event: "session_exit" }); + } + return sendNoContent(res); + } + // Back: the reserved "Go back" control steps ONE gate backwards and + // re-emits it (so the reviewer can revise the previous answer). The cursor + // is clamped at 0, so "back" on the first gate re-presenta the same gate. + // The re-emit is delayed for the same reason the normal advance is: the + // browser's WidgetHost calls setLastUserEntry (which clears stepMessages) + // and clearPending on POST completion, and our ui_request must land AFTER + // those local state updates so the gate actually renders (otherwise the + // spinner stays on: pendingWidget was cleared and the new ui_request races + // the clear, sometimes landing before it). + if (uiResponse.control === "back") { + cursor = Math.max(0, cursor - 1); + const backIdx = cursor; + console.error(`[back] torno al gate ${backIdx + 1}/${GATES.length}`); + setTimeout(() => { + if (sseClient) emitCurrentGate(sseClient); + }, 150); + return sendNoContent(res); + } const isRestartId = typeof uiResponse.id === "string" && uiResponse.id.startsWith(RESTART_ID_PREFIX); if (isRestartId) { // The final "completed" select carries a RESTART_ID_PREFIX id with two @@ -265,12 +299,14 @@ const server = createServer(async (req, res) => { } return sendNoContent(res); } - // Restart (default): close SSE to reconnect. + // Restart (default): reset the cursor and re-emit gate 1 in-band. We + // deliberately do NOT close the SSE — see the resume handler above for + // why closing caused a reconnect loop that hung the spinner. + cursor = 0; if (sseClient) { - try { sseClient.end(); } catch { /* already closed */ } - sseClient = null; + console.error("[restart] replay richiesto di nuovo, ri-emetto gate 1 in-band"); + emitCurrentGate(sseClient); } - console.error("[restart] replay richiesto di nuovo dall'utente"); return sendNoContent(res); } const { index, gate } = currentGate(); @@ -345,17 +381,21 @@ const server = createServer(async (req, res) => { return sendJson(res, 200, buildSessionDocuments(REPLAY_SESSION_ID)); } - // Resume: the frontend's doResume reuses the SAME SSE connection when the - // session is already active (the useSessionStream effect only re-runs on - // sessionId change), so simply resetting the cursor wouldn't reach the - // client. We forcibly CLOSE the current SSE connection: the browser's - // EventSource auto-reconnects, opening a fresh stream that resets the cursor - // to 0 and re-emits gate 1. This makes "resume immediately after the last - // gate" work without a hard refresh. + // Resume: reset the cursor to 0 and re-emit gate 1 on the current SSE + // connection (if any). We deliberately do NOT close the SSE: the frontend's + // doResume reuses the SAME SSE connection when the session is already active + // (useSessionStream only re-runs on sessionId change), and forcing a close + // here triggered an EventSource auto-reconnect loop that raced with the + // POST /response flow, leaving the spinner stuck after the first gate. + // Re-emitting gate 1 in-band is enough: applyEvent sets pendingWidget on + // any ui_request, so the widget renders and the spinner stops. if (method === "POST" && path === `/sessions/${REPLAY_SESSION_ID}/resume`) { + cursor = 0; if (sseClient) { - try { sseClient.end(); } catch { /* already closed */ } - sseClient = null; + console.error("[resume] reset cursore a 0, ri-emetto gate 1 in-band"); + emitCurrentGate(sseClient); + } else { + console.error("[resume] reset cursore a 0 (nessun client SSE attivo)"); } return sendNoContent(res); }