feat(replay): infer workflow phase from gate title so WorkflowBar lights the right dot
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.
This commit is contained in:
@@ -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;
|
||||
|
||||
+53
-13
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user