feat: gate decision-type validation, force-advance, and frontend fixes
Gate (tht-gate.js): - Pre-validate decision types against workflow.yaml before showing reviewer widget - Reject decisions emitted by later phases (min-phase check) - Copy top-level `kind` into artifact when model forgets it (prevents loop) - Force-advance on reviewer_decide/schema_linking when advance:true — skip redundant reviewer_confirm gate Backend: - Emit agent_end on clean Pi exit (code 0 + bridge idle) instead of marking failed Frontend: - Strip <think> tags from transcript and activity panel - Fix mermaid render with offscreen container + cleanup - Graceful mermaid error: show source code instead of red error, fall back to table Workflow: - F2 now emits table_promoted and table_excluded (early schema linking decisions) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -122,6 +122,14 @@ export class PiProcessManager {
|
|||||||
child.on("exit", (code) => {
|
child.on("exit", (code) => {
|
||||||
console.error(`[pi:${sessionId}] exited code=${code ?? "?"} mapped=${this.runtimes.get(sessionId) === runtime}`);
|
console.error(`[pi:${sessionId}] exited code=${code ?? "?"} mapped=${this.runtimes.get(sessionId) === runtime}`);
|
||||||
if (this.runtimes.get(sessionId) === runtime) {
|
if (this.runtimes.get(sessionId) === runtime) {
|
||||||
|
// Clean exit (code 0) while the bridge is idle means the model completed its
|
||||||
|
// turn and Pi shut down normally (e.g. after session finalization). Just clean
|
||||||
|
// up — no failure events.
|
||||||
|
if (code === 0 && runtime.bridge.turnState() === "idle") {
|
||||||
|
this.runtimes.delete(sessionId);
|
||||||
|
runtime.bridge.emitClientEvent({ type: "system_event", event: "agent_end" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
runtime.bridge.markFailed();
|
runtime.bridge.markFailed();
|
||||||
this.runtimes.delete(sessionId);
|
this.runtimes.delete(sessionId);
|
||||||
runtime.bridge.emitClientEvent({
|
runtime.bridge.emitClientEvent({
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { isNearBottom } from "./activityScroll";
|
|||||||
function transcriptLines(transcript: Array<{ text: string }>): string[] {
|
function transcriptLines(transcript: Array<{ text: string }>): string[] {
|
||||||
return transcript.flatMap(({ text }) =>
|
return transcript.flatMap(({ text }) =>
|
||||||
text
|
text
|
||||||
|
.replace(/<think>[\s\S]*?<\/think>/g, "")
|
||||||
|
.replace(/<\/?think>/g, "")
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.trimEnd())
|
.map((line) => line.trimEnd())
|
||||||
.filter((line) => line.trim() !== ""),
|
.filter((line) => line.trim() !== ""),
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export function isVisibleModelActivity(entry: ActivityEntry): boolean {
|
|||||||
|
|
||||||
export function formatModelActivity(text: string): string {
|
export function formatModelActivity(text: string): string {
|
||||||
return text
|
return text
|
||||||
|
.replace(/<think>[\s\S]*?<\/think>/g, "")
|
||||||
|
.replace(/<\/?think>/g, "")
|
||||||
.replace(/\r\n?/g, "\n")
|
.replace(/\r\n?/g, "\n")
|
||||||
.replace(/([.!?])(?=[A-ZÀ-ÖØ-Þ])/g, "$1\n\n")
|
.replace(/([.!?])(?=[A-ZÀ-ÖØ-Þ])/g, "$1\n\n")
|
||||||
.replace(/^[\t ]*[•‣–]\s+/gm, "- ")
|
.replace(/^[\t ]*[•‣–]\s+/gm, "- ")
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function MermaidBlock({ code }: { code: string }) {
|
|||||||
);
|
);
|
||||||
}, [code]);
|
}, [code]);
|
||||||
|
|
||||||
if (error) return <pre className="text-destructive text-xs">{error}</pre>;
|
if (error) return <pre className="overflow-auto rounded-lg bg-muted p-3 text-xs opacity-60">{code}</pre>;
|
||||||
if (!svg) return <pre className="text-xs opacity-50">{code}</pre>;
|
if (!svg) return <pre className="text-xs opacity-50">{code}</pre>;
|
||||||
// eslint-disable-next-line react/no-danger
|
// eslint-disable-next-line react/no-danger
|
||||||
return <div dangerouslySetInnerHTML={{ __html: svg }} />;
|
return <div dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||||
|
|||||||
@@ -116,9 +116,9 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
|
|||||||
if (view !== "chart" || oversized) return;
|
if (view !== "chart" || oversized) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const def = buildErDiagram(promoted, linking.joins);
|
const def = buildErDiagram(promoted, linking.joins);
|
||||||
renderMermaid(def).then((s) => {
|
renderMermaid(def)
|
||||||
if (!cancelled) setSvg(s);
|
.then((s) => { if (!cancelled) setSvg(s); })
|
||||||
});
|
.catch(() => { if (!cancelled) setView("table"); });
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ export async function renderMermaid(def: string): Promise<string> {
|
|||||||
_initialized = true;
|
_initialized = true;
|
||||||
}
|
}
|
||||||
const id = `mermaid-${Math.random().toString(36).slice(2)}`;
|
const id = `mermaid-${Math.random().toString(36).slice(2)}`;
|
||||||
const { svg } = await mermaid.render(id, def);
|
const container = document.createElement("div");
|
||||||
return svg;
|
container.style.position = "absolute";
|
||||||
|
container.style.left = "-9999px";
|
||||||
|
document.body.appendChild(container);
|
||||||
|
try {
|
||||||
|
const { svg } = await mermaid.render(id, def, container);
|
||||||
|
return svg;
|
||||||
|
} finally {
|
||||||
|
container.remove();
|
||||||
|
document.getElementById(id)?.remove();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ export function prepareReviewerArguments(input) {
|
|||||||
if (args.artifact && typeof args.artifact === "object" && args.artifact.data !== undefined) {
|
if (args.artifact && typeof args.artifact === "object" && args.artifact.data !== undefined) {
|
||||||
args.artifact = { ...args.artifact, data: jsonObjectOrSelf(args.artifact.data) };
|
args.artifact = { ...args.artifact, data: jsonObjectOrSelf(args.artifact.data) };
|
||||||
}
|
}
|
||||||
|
// Models frequently forget artifact.kind while providing it at top level —
|
||||||
|
// copy it over so validation doesn't loop.
|
||||||
|
if (args.artifact && typeof args.artifact === "object" && !args.artifact.kind && args.kind) {
|
||||||
|
args.artifact = { ...args.artifact, kind: args.kind };
|
||||||
|
}
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,6 +280,52 @@ function phaseId(ctx, num) {
|
|||||||
const p = meta.phases.find((x) => x.num === num);
|
const p = meta.phases.find((x) => x.num === num);
|
||||||
return p ? p.id : "?";
|
return p ? p.id : "?";
|
||||||
}
|
}
|
||||||
|
// Known decision types from workflow.yaml emits + meta types. Pre-validates decision
|
||||||
|
// payloads BEFORE showing the reviewer widget so a typo in the type name doesn't waste
|
||||||
|
// a human interaction (the CLI would reject it only after the reviewer has already picked).
|
||||||
|
let _knownTypes = null;
|
||||||
|
function knownDecisionTypes(ctx) {
|
||||||
|
if (_knownTypes) return _knownTypes;
|
||||||
|
const meta = phaseMeta(ctx);
|
||||||
|
const types = new Set([
|
||||||
|
"phase_approved", "phase_auto_approved", "phase_reopened",
|
||||||
|
"phase_skipped", "decision_retracted",
|
||||||
|
]);
|
||||||
|
for (const p of meta.phases) {
|
||||||
|
for (const t of (p.emits || [])) types.add(t);
|
||||||
|
}
|
||||||
|
_knownTypes = types;
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
function decisionMinPhaseMap(ctx) {
|
||||||
|
const meta = phaseMeta(ctx);
|
||||||
|
const mins = {};
|
||||||
|
for (const p of meta.phases) {
|
||||||
|
for (const t of (p.emits || [])) {
|
||||||
|
if (!(t in mins) || p.num < mins[t]) mins[t] = p.num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mins;
|
||||||
|
}
|
||||||
|
function validateDecisionTypes(ctx, options, session) {
|
||||||
|
const known = knownDecisionTypes(ctx);
|
||||||
|
for (const o of options) {
|
||||||
|
if (o.decision && !known.has(o.decision.type)) {
|
||||||
|
return `Tipo di decisione '${o.decision.type}' non valido. Tipi ammessi: ${[...known].join(", ")}. Correggi e riprova.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (session) {
|
||||||
|
const cur = currentPhase(ctx, session);
|
||||||
|
const mins = decisionMinPhaseMap(ctx);
|
||||||
|
for (const o of options) {
|
||||||
|
if (o.decision && mins[o.decision.type] && mins[o.decision.type] > cur) {
|
||||||
|
return `Tipo '${o.decision.type}' ammesso dalla Fase ${mins[o.decision.type]}, sessione alla Fase ${cur}. Chiudi prima la fase corrente.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Full phase descriptor {id, num, name} for the v2 phase-summary payload's `phase`.
|
// Full phase descriptor {id, num, name} for the v2 phase-summary payload's `phase`.
|
||||||
function phaseMetaForNum(ctx, num) {
|
function phaseMetaForNum(ctx, num) {
|
||||||
const meta = phaseMeta(ctx);
|
const meta = phaseMeta(ctx);
|
||||||
@@ -316,6 +367,21 @@ function advanceIfReady(ctx, session) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unconditional phase advance — the reviewer already approved via the widget interaction
|
||||||
|
// (reviewer_decide selection IS the human confirmation). Used when reviewer_decide persists
|
||||||
|
// substantive decisions with advance:true, so no separate reviewer_confirm gate is needed.
|
||||||
|
function forceAdvance(ctx, session) {
|
||||||
|
try {
|
||||||
|
tht(ctx, ["phase", "advance", "--session", session]);
|
||||||
|
return { advanced: true };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
advanced: false,
|
||||||
|
error: (e.stderr || e.message || String(e)).toString().trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- widget emission + wait — usa l'API UI NATIVA di Pi (ctx.ui.input) --------
|
// --- widget emission + wait — usa l'API UI NATIVA di Pi (ctx.ui.input) --------
|
||||||
//
|
//
|
||||||
// emitAndWait(ctx, descriptor) sends the widget-descriptor as JSON in the `title`
|
// emitAndWait(ctx, descriptor) sends the widget-descriptor as JSON in the `title`
|
||||||
@@ -662,6 +728,8 @@ export default function (pi) {
|
|||||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||||
lockActive = true;
|
lockActive = true;
|
||||||
const { session, title, options: opts, intro, advance } = params;
|
const { session, title, options: opts, intro, advance } = params;
|
||||||
|
const typeErr = validateDecisionTypes(ctx, opts, session);
|
||||||
|
if (typeErr) return textResult(typeErr);
|
||||||
const phase = phaseId(ctx, currentPhase(ctx, session));
|
const phase = phaseId(ctx, currentPhase(ctx, session));
|
||||||
const recommended = opts.find((o) => o.recommended)?.id ?? null;
|
const recommended = opts.find((o) => o.recommended)?.id ?? null;
|
||||||
|
|
||||||
@@ -734,6 +802,8 @@ export default function (pi) {
|
|||||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||||
lockActive = true;
|
lockActive = true;
|
||||||
const { session, title, options: opts, advance } = params;
|
const { session, title, options: opts, advance } = params;
|
||||||
|
const typeErr = validateDecisionTypes(ctx, opts, session);
|
||||||
|
if (typeErr) return textResult(typeErr);
|
||||||
const phase = phaseId(ctx, currentPhase(ctx, session));
|
const phase = phaseId(ctx, currentPhase(ctx, session));
|
||||||
const toAdd = [];
|
const toAdd = [];
|
||||||
const meritOptions = opts
|
const meritOptions = opts
|
||||||
@@ -813,12 +883,17 @@ export default function (pi) {
|
|||||||
toAdd.push(d);
|
toAdd.push(d);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (advance) advanceIfReady(ctx, session);
|
if (!toAdd.length) {
|
||||||
return textResult(
|
const adv = advance ? advanceIfReady(ctx, session) : { advanced: false };
|
||||||
toAdd.length
|
const msg = "Nessuna decisione registrata (il reviewer non ha selezionato opzioni di merito).";
|
||||||
? `Registrate ${toAdd.length} decisioni: ${toAdd.map((d) => d.type).join(", ")}.`
|
return textResult(adv.advanced ? msg + " Fase avanzata automaticamente." : msg);
|
||||||
: "Nessuna decisione registrata (il reviewer non ha selezionato opzioni di merito).",
|
}
|
||||||
);
|
// Decisions were recorded AND advance requested: the reviewer's selection IS the
|
||||||
|
// approval — force-advance without a separate reviewer_confirm gate.
|
||||||
|
const adv = advance ? forceAdvance(ctx, session) : { advanced: false };
|
||||||
|
const parts = [`Registrate ${toAdd.length} decisioni: ${toAdd.map((d) => d.type).join(", ")}.`];
|
||||||
|
if (adv.advanced) parts.push("Fase avanzata automaticamente — nessun gate aggiuntivo necessario.");
|
||||||
|
return textResult(parts.join(" "));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -924,8 +999,10 @@ export default function (pi) {
|
|||||||
const eSync = relayIfThtFails(ctx, ["session", "sync-schema-linking", session], "");
|
const eSync = relayIfThtFails(ctx, ["session", "sync-schema-linking", session], "");
|
||||||
if (eSync) return eSync;
|
if (eSync) return eSync;
|
||||||
|
|
||||||
if (advance) advanceIfReady(ctx, session);
|
const adv = advance ? forceAdvance(ctx, session) : { advanced: false };
|
||||||
return textResult(`Schema linking registrato dal reviewer (${n} tabelle + colonne curate).`);
|
const parts = [`Schema linking registrato dal reviewer (${n} tabelle + colonne curate). schema_linking.json scritto.`];
|
||||||
|
if (adv.advanced) parts.push("Fase avanzata automaticamente — nessun gate aggiuntivo necessario.");
|
||||||
|
return textResult(parts.join(" "));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from tht.workflow import load_workflow
|
|||||||
("concept_clarified", 1),
|
("concept_clarified", 1),
|
||||||
("memory_rejected", 2),
|
("memory_rejected", 2),
|
||||||
("question_rewritten", 3),
|
("question_rewritten", 3),
|
||||||
("table_promoted", 4),
|
("table_promoted", 2),
|
||||||
("column_corrected", 4),
|
("column_corrected", 4),
|
||||||
("evidence_accepted", 4),
|
("evidence_accepted", 4),
|
||||||
("value_grounded", 4),
|
("value_grounded", 4),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ phases:
|
|||||||
advance: auto_if_empty
|
advance: auto_if_empty
|
||||||
prerequisites: []
|
prerequisites: []
|
||||||
artifacts_out: []
|
artifacts_out: []
|
||||||
emits: [memory_rejected]
|
emits: [memory_rejected, table_promoted, table_excluded]
|
||||||
- id: F3
|
- id: F3
|
||||||
name: riscrittura
|
name: riscrittura
|
||||||
advance: kind:phase
|
advance: kind:phase
|
||||||
|
|||||||
Reference in New Issue
Block a user