refactor(workflow): contract shared core (#33)

This commit is contained in:
2026-08-24 03:12:00 +02:00
parent f375515dc0
commit 36a7a0ab33
19 changed files with 173 additions and 29 deletions
@@ -0,0 +1,136 @@
// artifact-contracts.js -- PURE soft-validators for the v2 gate payloads (WS2).
//
// No I/O, no TypeBox: hand-rolled, permissive on extra fields. Each validator
// returns { ok: boolean, errors: string[] } with actionable messages so the model
// can self-correct (the gate returns them as a textResult, never a hard TypeBox
// failure -- models loop when validation fails BEFORE execute()).
//
// A payload without `schema_version === 2` is NOT an error: it returns
// { ok: true, legacy: true } and the caller proceeds on the legacy path.
"use strict";
function isNonEmptyString(v) {
return typeof v === "string" && v.length > 0;
}
function isArray(v) {
return Array.isArray(v);
}
const VALID_CHECK_STATUS = new Set(["ok", "warn", "fail"]);
// Contract A -- cte_plan v2. Validates the model-owned skeleton; catalog-derived
// fields (index, table/filter descriptions) are filled later by enrich, so they
// are NOT required here.
function validateCtePlanV2(data) {
if (!data || typeof data !== "object" || data.schema_version !== 2) {
return { ok: true, legacy: true };
}
const errors = [];
if (!isArray(data.ctes) || data.ctes.length === 0) {
errors.push("ctes deve essere un array non vuoto.");
return { ok: false, errors };
}
const seen = [];
data.ctes.forEach((c, i) => {
if (!c || typeof c !== "object") {
errors.push(`ctes[${i}] deve essere un oggetto.`);
return;
}
if (!isNonEmptyString(c.name)) {
errors.push(`ctes[${i}].name mancante o vuoto.`);
}
if (isArray(c.depends_on)) {
for (const dep of c.depends_on) {
if (!seen.includes(dep)) {
errors.push(
`ctes[${i}].depends_on contiene '${dep}' che non è un CTE precedente.`,
);
}
}
}
if (isNonEmptyString(c.name)) seen.push(c.name);
});
return { ok: errors.length === 0, errors };
}
// Contract B -- cte_result THIN (what the model sends). The gate builds the full
// payload from `tht cte info`; the model only supplies {purpose?, rationale?, note?}.
// Everything is optional, so a thin v2 artifact is valid as long as it declares v2.
function validateCteResultThin(data) {
if (!data || typeof data !== "object" || data.schema_version !== 2) {
return { ok: true, legacy: true };
}
const errors = [];
for (const field of ["purpose", "rationale", "note"]) {
if (data[field] !== undefined && typeof data[field] !== "string") {
errors.push(`${field} deve essere una stringa se presente.`);
}
}
return { ok: errors.length === 0, errors };
}
// Contract C -- phase summary v2.
function validatePhaseSummaryV2(data) {
if (!data || typeof data !== "object" || data.schema_version !== 2) {
return { ok: true, legacy: true };
}
const errors = [];
if (!isNonEmptyString(data.summary)) {
errors.push("summary mancante o vuoto.");
}
if (data.checks !== undefined) {
if (!isArray(data.checks)) {
errors.push("checks deve essere un array se presente.");
} else {
data.checks.forEach((c, i) => {
if (!c || typeof c !== "object" || !isNonEmptyString(c.label)) {
errors.push(`checks[${i}].label mancante o vuoto.`);
}
if (!c || !VALID_CHECK_STATUS.has(c.status)) {
errors.push(`checks[${i}].status deve essere uno tra ok|warn|fail.`);
}
});
}
}
if (data.sections !== undefined) {
if (!isArray(data.sections)) {
errors.push("sections deve essere un array se presente.");
} else {
data.sections.forEach((s, i) => {
if (!s || typeof s !== "object") {
errors.push(`sections[${i}] deve essere un oggetto.`);
return;
}
if (!isNonEmptyString(s.title)) {
errors.push(`sections[${i}].title mancante o vuoto.`);
}
if (s.items !== undefined && !isArray(s.items)) {
errors.push(`sections[${i}].items deve essere un array se presente.`);
}
});
}
}
if (data.tables !== undefined && !isArray(data.tables)) {
errors.push("tables deve essere un array se presente.");
}
if (data.open_questions !== undefined) {
if (!isArray(data.open_questions)) {
errors.push("open_questions deve essere un array di stringhe se presente.");
} else {
data.open_questions.forEach((question, i) => {
if (typeof question !== "string") {
errors.push(`open_questions[${i}] deve essere una stringa.`);
}
});
}
}
return { ok: errors.length === 0, errors };
}
module.exports = {
validateCtePlanV2,
validateCteResultThin,
validatePhaseSummaryV2,
};
@@ -0,0 +1,219 @@
// Pure widget-descriptor builders (spec D2/D4, §4.1).
//
// Each function turns plain params into a ui_request descriptor object. No Pi
// context, no I/O -- this is the part of the gate that is fully testable in L1
// (in JS, in-language, no Python mirror). The glue (emission via ctx.sendRaw,
// anti-bypass, the no-limbo loop) is in tht-gate.js and is verified at L2.
//
// The 6 widget kinds: info, select, multiselect, freetext, artifact-gate, artifact.
// `widget` is an open field (a new kind needs a renderer, not infra changes);
// these are the initial 6. reserved = framework escape hatches always present on
// blocking widgets: back / exit / other.
"use strict";
const SCHEMA_VERSION = 1;
const RESERVED = ["back", "exit", "other"];
const VALID_ACTION_KINDS = new Set(["confirm", "approve_reject", "view_only"]);
const VALID_INFO_LEVELS = new Set(["info", "warning", "error"]);
function requireString(value, name, ctx) {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`builders: ${ctx} requires a non-empty ${name}`);
}
return value;
}
function requireArray(value, name, ctx) {
if (!Array.isArray(value)) {
throw new Error(`builders: ${ctx} requires ${name} to be an array`);
}
return value;
}
// Build a `select` ui_request (single-pick). The framework escape hatches
// (Back/Exit/Other) come from `reserved` (rendered by ReservedControls, which owns
// the free-text linkage on "Other"). `recommended` is the id of the option to
// highlight as "(consigliato)".
function buildSelectRequest({
id, phase, title, options, intro = null, recommended = null,
}) {
requireString(title, "title", "select");
const opts = requireArray(options, "options", "select");
const out = {
type: "ui_request",
id,
phase,
schema_version: SCHEMA_VERSION,
widget: "select",
title,
intro,
recommended,
options: [...opts],
reserved: RESERVED,
};
return out;
}
// Build a `multiselect` ui_request (multi-pick). `content` is the scrollable
// context (e.g. schema candidates); `selected` the pre-checked option ids;
// `allowEmpty:false` with zero options is a broken widget and throws.
function buildMultiselectRequest({
id, phase, title, options, content = null, selected = [], allowEmpty = false,
selectionLabel, confirmLabel,
}) {
requireString(title, "title", "multiselect");
const opts = requireArray(options, "options", "multiselect");
if (!allowEmpty && opts.length === 0) {
throw new Error(
"builders: multiselect with allow_empty:false requires at least one option",
);
}
return {
type: "ui_request",
id,
phase,
schema_version: SCHEMA_VERSION,
widget: "multiselect",
title,
allow_empty: allowEmpty,
options: opts.map((option) => ({ ...option, selected: selected.includes(option.id) })),
selected: [...selected],
content,
reserved: RESERVED,
...(selectionLabel ? { selection_label: selectionLabel } : {}),
...(confirmLabel ? { confirm_label: confirmLabel } : {}),
};
}
// Build an `artifact-gate` ui_request: a rendered artifact + a disposition list
// (confirm / approve_reject / view_only). The decision is load-bearing on the
// document, so the artifact is never shown alone (spec §4.1).
function buildArtifactGate({ id, phase, title, artifact, action }) {
requireString(title, "title", "artifact-gate");
if (!artifact || typeof artifact !== "object") {
throw new Error("builders: artifact-gate requires an artifact object");
}
if (!artifact.kind) {
throw new Error("builders: artifact-gate artifact requires a kind");
}
if (!action || !action.kind || !VALID_ACTION_KINDS.has(action.kind)) {
throw new Error(
`builders: artifact-gate action.kind must be one of ${[...VALID_ACTION_KINDS].join(", ")}`,
);
}
const ACTION_OPTIONS = {
approve_reject: [
{ id: "approve", label: "Salva e procedi", recommended: true },
{ id: "reject", label: "Rifiuta" },
],
confirm: [{ id: "approve", label: "Salva e procedi", recommended: true }],
view_only: [],
};
return {
type: "ui_request",
id,
phase,
schema_version: SCHEMA_VERSION,
widget: "artifact-gate",
title,
artifact,
action,
options: ACTION_OPTIONS[action.kind],
reserved: RESERVED,
};
}
// Build an `info` ui_request: fire-and-forget notification (no response expected,
// no reserved hatches, no correlated id).
function buildInfoRequest({ phase, level, text }) {
if (!level || !VALID_INFO_LEVELS.has(level)) {
throw new Error(
`builders: info level must be one of ${[...VALID_INFO_LEVELS].join(", ")}`,
);
}
requireString(text, "text", "info");
return {
type: "ui_request",
phase,
schema_version: SCHEMA_VERSION,
widget: "info",
level,
text,
};
}
// Build a `freetext` ui_request: free-text input, always a child of
// select/artifact-gate (via Altro/Rifiuta) or via the ambient steering channel.
function buildFreetextRequest({ id, phase, title }) {
requireString(title, "title", "freetext");
return {
type: "ui_request",
id,
phase,
schema_version: SCHEMA_VERSION,
widget: "freetext",
title,
reserved: RESERVED,
};
}
// Build a `schema-linking` ui_request: table rows (promote/exclude) each carrying
// their full catalog columns; the frontend renders rows + a per-table columns
// modal (suggested pre-selected). `tables` is validated shallowly here.
function buildSchemaLinkingRequest({ id, phase, title, tables }) {
requireString(title, "title", "schema-linking");
const tabs = requireArray(tables, "tables", "schema-linking");
return {
type: "ui_request",
id,
phase,
schema_version: SCHEMA_VERSION,
widget: "schema-linking",
title,
tables: [...tabs],
reserved: RESERVED,
};
}
// Build a blocking, read-only join review. The reviewer can accept the complete
// proposal or use Other to request a textual correction; individual joins are
// deliberately not selectable because omitting one could create a Cartesian product.
function buildJoinReviewRequest({ id, phase, title, options }) {
requireString(title, "title", "join-review");
const opts = requireArray(options, "options", "join-review");
if (opts.length === 0) {
throw new Error("builders: join-review requires at least one option");
}
return {
type: "ui_request",
id,
phase,
schema_version: SCHEMA_VERSION,
widget: "join-review",
title,
options: [...opts],
confirm_label: "Continue",
reserved: RESERVED,
};
}
// Attach a child-widget spec to an option (linkage, §4.2). Returns the option.
function withChildLinkage(option, widgetSpec) {
option.opens = widgetSpec;
return option;
}
module.exports = {
buildSelectRequest,
buildMultiselectRequest,
buildArtifactGate,
buildInfoRequest,
buildFreetextRequest,
buildSchemaLinkingRequest,
buildJoinReviewRequest,
withChildLinkage,
SCHEMA_VERSION,
RESERVED,
};
+182
View File
@@ -0,0 +1,182 @@
// enrich.js -- PURE merge/enrichment for the v2 gate payloads (WS2).
//
// Catalog lookups are INJECTED as `getColumns` params (for testability): a function
// (tableName) => { description, columns: [{ name, description, ... }] } | null.
// A catalog miss (null) yields empty descriptions, never an error (the hard-fail
// stays only in reviewer_schema_linking).
//
// No dependency on tht-gate.js: importable and testable in isolation.
"use strict";
// Memoize a raw catalog lookup so repeated table hits do a single I/O call. The
// gate wraps its `tht schema columns <t> --json` (try/catch -> null on miss) and
// passes it here; enrich itself stays pure.
function memoizeGetColumns(rawGetColumns) {
const cache = new Map();
return function getColumns(tableName) {
if (cache.has(tableName)) return cache.get(tableName);
const v = rawGetColumns(tableName);
cache.set(tableName, v);
return v;
};
}
function columnDescription(cat, colName) {
if (!cat || !Array.isArray(cat.columns)) return "";
const col = cat.columns.find((c) => c.name === colName);
return (col && col.description) || "";
}
// Contract A. Fills index (1-based), tables[].description and filters[].description
// via getColumns. For filters, `column: "tab.col"` resolves to the column's catalog
// description. Returns a NEW object; model-owned fields pass through unchanged.
function enrichCtePlanV2(data, getColumns) {
const ctes = (data.ctes || []).map((cte, i) => {
const out = { ...cte, index: i + 1 };
if (Array.isArray(cte.tables)) {
out.tables = cte.tables.map((t) => {
const cat = getColumns(t.name);
return { ...t, description: (cat && cat.description) || "" };
});
}
if (Array.isArray(cte.filters)) {
out.filters = cte.filters.map((f) => {
const out2 = { ...f };
if (typeof f.column === "string" && f.column.includes(".")) {
const idx = f.column.indexOf(".");
const tableName = f.column.slice(0, idx);
const colName = f.column.slice(idx + 1);
out2.description = columnDescription(getColumns(tableName), colName);
} else {
out2.description = "";
}
return out2;
});
}
return out;
});
return { ...data, ctes };
}
// Contract B. Fuses the model's thin data with `tht cte info` output.
// name/index/total/sql <- cteInfo
// purpose/rationale/depends_on <- cteInfo.doc (fallback: thinData)
// status/execution_ms/row_sample/warnings/sql_hash/preview <- cteInfo.last_test
// note <- thinData
// preview = { columns: last_test.columns, rows: last_test.preview_rows }.
function buildCteResultV2(thinData, cteInfo) {
const thin = thinData || {};
const doc = cteInfo.doc || {};
const lt = cteInfo.last_test || {};
const pick = (docVal, thinVal) => (docVal !== undefined ? docVal : thinVal);
return {
schema_version: 2,
name: cteInfo.name,
index: cteInfo.index,
total: cteInfo.total,
purpose: pick(doc.purpose, thin.purpose),
rationale: pick(doc.rationale, thin.rationale),
depends_on: pick(doc.depends_on, thin.depends_on),
sql: cteInfo.sql,
status: lt.status,
execution_ms: lt.execution_ms,
row_sample: lt.row_sample,
warnings: lt.warnings || [],
sql_hash: lt.sql_hash,
columns: (lt.columns || []).map((name) => ({ name, description: "" })),
preview: { columns: lt.columns || [], rows: lt.preview_rows || [] },
note: thin.note,
};
}
// Contract C. Fills `phase` from phaseMeta and the `description` fields in
// tables[]/tables[].columns[]/sections[].items[] via getColumns. phaseMeta is
// { id, num, name }. Returns a NEW object.
function enrichPhaseSummaryV2(data, phaseMeta, getColumns) {
const out = { ...data, phase: phaseMeta };
if (Array.isArray(data.tables)) {
out.tables = data.tables.map((t) => {
const cat = getColumns(t.name);
const enriched = { ...t, description: (cat && cat.description) || "" };
if (Array.isArray(t.columns)) {
enriched.columns = t.columns.map((c) => ({
...c,
description: columnDescription(cat, c.name),
}));
}
return enriched;
});
}
if (Array.isArray(data.sections)) {
out.sections = data.sections.map((s) => {
if (!Array.isArray(s.items)) return { ...s };
return {
...s,
items: s.items.map((it) => {
// An item's description resolves against its declared table+column.
let desc = "";
if (typeof it.table === "string" && typeof it.column === "string") {
desc = columnDescription(getColumns(it.table), it.column);
} else if (typeof it.table === "string") {
const cat = getColumns(it.table);
desc = (cat && cat.description) || "";
}
return { ...it, description: desc };
}),
};
});
}
return out;
}
// Fills columns[].description of a built cte_result payload by matching each column
// name against the catalog of the plan's tables (`planTables`: string[]). First
// table whose catalog carries the column wins; unresolved -> empty description.
// Mutates a copy of payload.columns; returns the payload.
function enrichCteResultColumns(payload, planTables, getColumns) {
if (!Array.isArray(payload.columns)) return payload;
const tables = Array.isArray(planTables) ? planTables : [];
const columns = payload.columns.map((col) => {
let desc = "";
for (const t of tables) {
const d = columnDescription(getColumns(t), col.name);
if (d) {
desc = d;
break;
}
}
return { ...col, description: desc };
});
return { ...payload, columns };
}
// Contract C bis. Appends a deterministic "decisions of this phase" section built
// from the session ledger, so the model's recap can stay thin (Discipline 6 exactness
// comes from persisted state, not model prose). `decisions` is session-show's ledger
// dump; `emits` is the phase's substantive decision-type list from workflow.yaml.
// No matching decisions -> data returned unchanged. Pure: returns a NEW object.
function appendLedgerSection(data, decisions, emits) {
const emitSet = new Set(emits || []);
const rows = (decisions || []).filter((d) => d && emitSet.has(d.type));
if (rows.length === 0) return data;
const section = {
title: "Decisioni registrate in questa fase (dal ledger)",
items: rows.map((d) => ({
label: `${d.type}: ${d.subject}`,
value: d.detail || "",
kind: "decision",
rationale: d.rationale || "",
})),
};
return { ...data, sections: [...(data.sections || []), section] };
}
module.exports = {
memoizeGetColumns,
enrichCtePlanV2,
buildCteResultV2,
enrichCteResultColumns,
enrichPhaseSummaryV2,
appendLedgerSection,
};
@@ -0,0 +1,43 @@
// Etichette riservate del gate HITL e helper di deduplica. Modulo PURO (nessuna
// dipendenza da pi-tui): condiviso da tht-gate.js e testabile in isolamento con
// node --test .pi/extensions/test_reserved_labels.mjs
// `Altro — specifica…` e le due vie di fuga (torna indietro / esci) vengono
// SEMPRE aggiunte dal gate alle opzioni in arrivo: se il modello ri-propone uno
// step con queste etichette gia' presenti, stripReserved evita il duplicato.
export const ALTRO = "Altro — specifica…";
export const QUIT_LABEL = "Esci da Pi (/quit)";
export const BACK_LABEL = "Torna indietro (fase precedente)";
export const CONTROL_LABELS = new Set([QUIT_LABEL, BACK_LABEL]);
// Normalize a label to lowercase ASCII tokens: strip diacritics, turn every run
// of punctuation/space into a single space, trim. "Altro — specifica…" -> "altro specifica".
function normalize(label) {
return String(label)
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
const NORM_QUIT = normalize(QUIT_LABEL);
const NORM_BACK = normalize(BACK_LABEL);
// true if the label is one the gate adds itself. Robust to the variants models
// emit ("Altro - specificare", "altro", English "Other — specify"): reserved when
// the FIRST normalized token is exactly "altro"/"other", or the whole normalized
// label equals the canonical quit/back labels. First-token match keeps real
// options like "altrove" out of the reserved set.
export function isReserved(label) {
const n = normalize(label);
if (!n) return false;
const first = n.split(" ")[0];
return first === "altro" || first === "other" || n === NORM_QUIT || n === NORM_BACK;
}
// removes every reserved entry from a list of labels, preserving order and normal
// entries. Idempotent.
export function stripReserved(labels) {
return labels.filter((label) => !isReserved(label));
}