chore: hygiene pass — ruff clean, docs storage-model truth, replay /me, failSession log

Audit findings 6.1-6.4 + the audit's remediation plan itself
(docs/superpowers/plans/2026-07-20-full-audit-remediation-plan.md).

- ruff: 34 → 0 (unused imports/f-strings auto-fixed; E702 semicolon lines
  split in test files; one unused local dropped). Suite still 819 green.
- CLAUDE.md + PROJECT_STATE.md no longer claim "no database / settings in
  settings.json": the harness selects filesystem OR PostgreSQL session
  storage (repository.py, server mode), and settings flow through harness
  preferences with the JSON file as fallback only.
- tools/replay: stub /me (SPA boot was parsing the SPA's own HTML as JSON)
  and /runtime/prewarm.
- failSession best-effort persistence now logs its failure server-side
  instead of vanishing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 01:55:41 +02:00
co-authored by Claude Fable 5
parent 1ab0015460
commit c4951e2aa5
21 changed files with 283 additions and 43 deletions
+10 -6
View File
@@ -52,11 +52,14 @@ frontend (React/SSE) → backend (Fastify) → pi --mode rpc → tht/harness →
There is no verbatim transcript store. A resumed Pi process rebuilds context from There is no verbatim transcript store. A resumed Pi process rebuilds context from
`tht session show <id>` + the on-disk artifacts. `tht session show <id>` + the on-disk artifacts.
- **The backend is a thin bridge with no database.** `ThtRunner` shells `tht` subcommands; - **The backend is a thin bridge with no database of its own.** `ThtRunner` shells `tht`
`PiProcessManager` runs one Pi child per session and bridges its RPC stream; subcommands; `PiProcessManager` runs one Pi child per session and bridges its RPC stream;
`SessionBridge` maps Pi RPC events → client events (`ui_request`/`text_delta`/`info`); `SessionBridge` maps Pi RPC events → client events (`ui_request`/`text_delta`/`info`);
`SseHub` fans them out over SSE to the browser. App settings live in a JSON file `SseHub` fans them out over SSE to the browser. Persistence belongs to the HARNESS, which
(`backend/data/settings.json`), not a DB. selects the session repository from the workspace config (`harness/tht/session/repository.py`):
filesystem by default, **PostgreSQL when `session_storage` is configured** (server/portable
deployment). Settings flow through harness preferences (`tht session preferences`) with
`backend/data/settings.json` only as the file fallback for injected runners/tests.
- **Human-in-the-loop gate contract.** The model proposes; a human reviewer decides at gates - **Human-in-the-loop gate contract.** The model proposes; a human reviewer decides at gates
via widgets (`reviewer_select` = single pick — a chosen option carrying a `decision` payload via widgets (`reviewer_select` = single pick — a chosen option carrying a `decision` payload
@@ -75,8 +78,9 @@ frontend (React/SSE) → backend (Fastify) → pi --mode rpc → tht/harness →
- **Workspaces** (`harness/workspaces/*.yaml`) set the DB target and **absolute** - **Workspaces** (`harness/workspaces/*.yaml`) set the DB target and **absolute**
`paths.sessions/artifacts/indexes` — for `psd` these point at a *separate, uncommitted* repo `paths.sessions/artifacts/indexes` — for `psd` these point at a *separate, uncommitted* repo
(`tht-workspace-psd/`). Secrets live ONLY in `harness/.env` (gitignored). (`tht-workspace-psd/`). Secrets live ONLY in `harness/.env` (gitignored).
- **Settings are global** (`backend/data/settings.json`: workspace/provider/model/thinking); - **Settings are global** (workspace/provider/model/thinking, persisted via harness
the New-session form is question-only. preferences — `backend/data/settings.json` is only the fallback); the New-session form is
question-only.
- **Resume**: a resumable session re-enters at its last incomplete phase. The backend refuses - **Resume**: a resumable session re-enters at its last incomplete phase. The backend refuses
resume with 409 when `finalized` or `archived`, and `PiProcessManager.spawnFor` must send resume with 409 when `finalized` or `archived`, and `PiProcessManager.spawnFor` must send
`/riprendi-sessione <id>` (resume mode) vs `/nuova-domanda` (new) — sending the wrong prompt `/riprendi-sessione <id>` (resume mode) vs `/nuova-domanda` (new) — sending the wrong prompt
+5 -2
View File
@@ -346,8 +346,11 @@ All three layers' deps installed (`npm install` in each, `python -m venv + pip i
repo `tht-workspace-psd/`, NOT committed here). repo `tht-workspace-psd/`, NOT committed here).
- Secrets live ONLY in `harness/.env` (gitignored; `THT_*` — DB, DWH REST, vector, SSL CA…). - Secrets live ONLY in `harness/.env` (gitignored; `THT_*` — DB, DWH REST, vector, SSL CA…).
See `harness/.env.example` for the variable list. See `harness/.env.example` for the variable list.
- App settings (global): `backend/data/settings.json` (gitignored) — `{ workspace, provider, - App settings (global): `{ workspace, provider, model, thinking }`, persisted via harness
model, thinking }`. The "New session" form is question-only; these settings supply the rest. preferences (`tht session preferences get/set` → the configured session repository —
filesystem or Postgres in server mode). `backend/data/settings.json` (gitignored) remains
only the file fallback for injected runners/tests. The "New session" form is question-only;
these settings supply the rest.
## Efficiency levers (NL→SQL workflow optimization, 2026-07-08) ## Efficiency levers (NL→SQL workflow optimization, 2026-07-08)
+5 -1
View File
@@ -98,7 +98,11 @@ export function sessionRoutes(
// old failure must not touch its manifest. // old failure must not touch its manifest.
const bound = boundRuntimes.get(id); const bound = boundRuntimes.get(id);
if (bound !== undefined && bound !== rt) return; if (bound !== undefined && bound !== rt) return;
await runner.failSession(id, workspace).catch(() => undefined); // Best-effort by design (crash path), but a storage outage must be
// visible server-side: the manifest stays open and resume remains legal.
await runner.failSession(id, workspace).catch((error: unknown) => {
console.error(`[session:${id}] failSession persistence failed:`, error);
});
}).catch(() => undefined); }).catch(() => undefined);
} }
} }
@@ -0,0 +1,210 @@
# Full-codebase audit — remediation plan (2026-07-20)
Source: adversarial review (3 Codex reviewers — Skeptic/Architect/Minimalist — per the
adversarial-review skill) + independent verification of every finding + objective checks.
Baseline at commit `803b98d`: harness 812 pytest pass (l0/l2 deselected), backend 228,
frontend 309, both `tsc` clean, **ruff 34 errors** (all in `harness/tests`).
Verdict: **REJECT** (per skill verdict logic: high-severity findings with multi-reviewer
consensus). The app is functional and green on tests, but ships 5 confirmed high-severity
defects. Nothing here is data-destroying in the happy path; the highs are silent-wrong-state
and workflow-integrity classes.
Every finding below was re-verified in the current code by the lead (line numbers checked).
Two reviewer findings were **rejected** in lead judgment and are listed at the bottom.
---
## FASE 1 — Pi-crash class closure (gate) — ~1h, no behavior change
**1.1 [HIGH] Five gate tools can still crash Pi on an uncaught throw.**
`harness/.pi/extensions/tht-gate.js` — `reviewer_memory_promote` (execute at ~1343),
`rewrite_question` (~1431), `write_schema_linking` (~1484), `write_cte_sql` (~1511),
`write_final_sql` (~1527). Same class as the crash fixed in `87cb806` for the four
`reviewer_*` tools: `currentPhase()`/`phaseId()`/`tht()` run outside any try/catch; a
throw rejects the async execute → Node unhandled rejection → Pi dies mid-session.
FIX: wrap each execute body in the same top-level try/catch → `textResult` pattern.
VERIFY: `node -c`; grep-test asserting every `registerTool` execute has a top-level try.
## FASE 2 — Workflow integrity (single-source the phase truth) — ~half day
**2.1 [HIGH] WorkflowBar phase labels are fiction from F2 onward.**
`frontend/src/shell/WorkflowBar.tsx:5-29` hardcodes
`F2 "Schema linking" / F3 "Exploration" / F4 "SQL plan" / F5 "SQL generation" / F6
"Validation" / F7 "Review"`, while `harness/workflow.yaml` defines
`F2 memoria / F3 riscrittura / F4 schema_linking / F5 sintesi / F6 cte / F7 sql_finale`.
Every live session shows the wrong phase name to the reviewer from F2 on.
FIX (minimal): correct the two static maps to the canonical sequence.
FIX (right): backend `GET /workflow/meta` shelling `tht phase meta --json`, frontend
fetches once per app load with the corrected static map as fallback.
VERIFY: unit test mapping `tht phase meta --json` ids/names ↔ rendered labels.
**2.2 [HIGH] Gate `forceAdvance` contradicts the canonical SKILL.md.**
`harness/.pi/skills/tht-sessione/SKILL.md:30-32`: "`advance:true` auto-advances only F2
(empty memory) and F6 (skipped/empty) — never a phase that recorded substantive
decisions." But since `6ee5bda`, `reviewer_decide` and `reviewer_schema_linking` with
`advance:true` **force-advance any phase** after persisting decisions
(`tht-gate.js` `forceAdvance`, used at ~895 and ~1002). The model reads one contract and
the gate implements another → nondeterministic workflow shape (phases may skip their
`reviewer_confirm kind:"phase"` summary gate depending on what the model passes).
The force-advance behavior was a deliberate product choice (kill the redundant approve
form); the defect is the contradiction, not the feature.
FIX: (a) restrict `forceAdvance` to the two designed auto-close gates
(`reviewer_schema_linking` in F4, `reviewer_memory_promote` in F8 via
`closeAfterPromotion`); `reviewer_decide` returns to `advanceIfReady` (exit-6 no-op);
(b) rewrite SKILL.md §"Phase map (advance cheat-sheet)" to document exactly which gates
auto-advance; (c) add the allowlist as data in `workflow.yaml` (e.g. `advance:
gate_closes`) so gate and skill read one source.
VERIFY: L1 test on the allowlist; live smoke on psd (F4 auto-advances, F5 does not).
## FASE 3 — Transport correctness (SSE) — ~half day
**3.1 [HIGH, 3/3 reviewer consensus] Stale `Last-Event-ID` from an older backend
generation is honored when ids collide.**
`backend/src/sse/sse-hub.ts:55-63`: the stale-cursor guard only catches
`cursor > lastId`. After a backend restart, ids restart at 1; a browser auto-reconnect
carrying cursor N from the old process silently suppresses the new generation's events
1..N (different content, same ids). Contract 11 in `brain/codebase/workflow-ui-contracts.md`
is only half-implemented.
FIX: make the event id a composite `"<generation>:<seq>"` (generation = process-start
epoch, e.g. `Date.now()` at Hub construction). `Last-Event-ID` is opaque to EventSource,
so no frontend change: on subscribe, parse the cursor; generation mismatch → treat as 0.
VERIFY: regression test — two Hub instances, cursor from A replayed against B must
replay from the beginning.
**3.2 [MEDIUM] Transport state never evicted for finished sessions.**
`sse-hub.ts:41-44` — `buffers`/`lastIds` grow per session; `forget()` is called only by
the delete route. Long-lived deployments accumulate state for every session ever touched.
FIX: call `hub.forget(id)` when a session reaches a terminal state (finalized+agent_end,
archived) — the transcript UI reads persisted documents, not the SSE buffer.
VERIFY: unit test — finalize → maps empty for that id.
## FASE 4 — Process & route robustness (backend) — ~half day
**4.1 [MEDIUM] `spawnFor` leaks a live child + registered runtime when `configure()`
rejects.** `backend/src/pi/pi-process-manager.ts:177-182`: `createFor` registers the
runtime; if `configure` (set_model/set_thinking RPC) rejects, `spawnFor` throws without
teardown → the map keeps an apparently-active runtime with a live Pi child; every later
start gets "session runtime already active".
FIX: try/catch in `spawnFor` → identity-checked teardown + rethrow.
VERIFY: unit test with a configure that rejects.
**4.2 [MEDIUM] No timeout on any `tht` call except `dbPing`.**
`backend/src/tht/tht-runner.ts:67-101`: `run()` supports `timeoutMs` but only `dbPing`
passes one. A DWH/vector op that hangs (VPN drop mid-call: `sql preview`, `search pack`,
`ollama ensure`) wedges the HTTP request forever.
FIX: default timeout in `run()` (60s) + explicit per-call values (dbPing 10s,
sqlPreview/searchPack 120s). On timeout the existing code-124 path already SIGKILLs.
VERIFY: unit test with a sleeping fake bin.
**4.3 [MEDIUM] Missing workspace YAML silently falls back to the default config.**
`tht-runner.ts:51-56`: `configArg("typo")` returns the default `-c` when
`workspaces/typo.yaml` doesn't exist → sessions/operations silently target the wrong
workspace (wrong DB, wrong sessions dir).
FIX: throw on a named-but-missing workspace; let routes surface 500 with the message.
VERIFY: unit test.
**4.4 [MEDIUM] Resume returns `200 alreadyActive` before the finalized/archived 409.**
`backend/src/routes/sessions.ts:288-296`: the runtime fast-path short-circuits the
read-only contract. A finalized session with a lingering running runtime resumes as if
live.
FIX: evaluate the manifest 409 first; only then the alreadyActive fast-path.
VERIFY: existing route test extended (finalized manifest + fake active runtime → 409).
**4.5 [LOW] `ollamaEnsure` treats exit-0 with unparseable stdout as ok.**
`tht-runner.ts:204-206`. FIX: on code 0 require parsed JSON (else ok:false with detail).
**4.6 [LOW] `respond()` sends stale gate responses with the wrong Pi RPC id.**
`backend/src/bridge/session-bridge.ts:110-117`: a retried response for gate A while B is
pending goes out with B's `pendingPiId`; Pi's descriptor-id check re-loops it (transient,
self-healing). FIX: drop the response unless `pending && uiResponse.id === pending.id`.
## FASE 5 — State integrity (harness + gate persistence) — ~1 day
**5.1 [MEDIUM] `phase reopen` deletes artifacts before recording `phase_reopened`.**
`harness/tht/cli/phase_cmd.py:124-128`: crash between `teardown_snapshot()` and
`append_decisions()` → artifacts of later phases deleted but ledger still at the old
phase; resume enters a phase whose expected artifacts are gone.
FIX: append `phase_reopened` FIRST, then teardown, and make teardown idempotent +
re-runnable: on session load, if the folded phase is behind surviving later-phase
artifacts, re-run the teardown repair pass. (Ledger-first means the half-state is
"reopened with stale extra artifacts", which the repair pass cleans deterministically.)
VERIFY: unit test simulating the crash window (teardown raises after ledger append).
**5.2 [MEDIUM] Schema-linking approval persists N decisions non-atomically.**
`tht-gate.js` ~967-1000: one `tht decision add` per table/column; a transient failure
mid-loop returns an error with the ledger half-written; a retry re-adds the first K
decisions (duplicate entries; projection is set-based so the artifact survives, but the
audit ledger lies).
FIX: batch the whole review into one CLI call (`tht decision add-batch --doc -` on the
model of the existing `add-join-set`), atomic single append.
VERIFY: L1 test; retry after injected failure produces no duplicates.
**5.3 [HIGH] Anti-bypass hook does not cover bash writes to protected state.**
`tht-gate.js:141-158` + hook 550-585: `FORBIDDEN` blocks specific `tht` subcommands and
`GATE_CODE_FILES` blocks the `write`/`edit` TOOLS, but plain bash can still mutate
protected state: `echo '{"type":"phase_approved"...}' >> review_decisions.jsonl`,
`sed -i` on `session_manifest.yaml`, `cat > .pi/extensions/tht-gate.js`.
This is defense against a *confused* model (it has already edited its own gate once —
see memory), not a hostile one; perfect sandboxing is out of scope.
FIX: extend the bash branch of the tool_call hook with a protected-path pattern: block
any bash command whose text references `review_decisions.jsonl`, `session_manifest.yaml`,
`cte_plan.json` or `.pi/extensions/` in a mutating context (`>`, `>>`, `tee`, `sed -i`,
`mv`, `cp`, `rm`, `python … open(...,'w')`). Read-only mentions (cat/grep) stay allowed.
VERIFY: L1 tests on the pattern (block list + allow list).
## FASE 6 — Docs & hygiene — ~1h
**6.1 [MEDIUM] CLAUDE.md/PROJECT_STATE describe a storage model that no longer exists.**
CLAUDE.md says "backend … no database", "App settings live in a JSON file
(`backend/data/settings.json`)". Since the portable-deployment merge the truth is:
`harness/tht/session/repository.py:69-86` selects **PostgresSessionRepository** when
`session_storage` is configured (server mode) vs filesystem; backend settings go through
harness preferences (`backend/src/app.ts:61-80`), with the JSON file as fallback only.
FIX: update CLAUDE.md architecture bullets + PROJECT_STATE.md; add one line on when each
repository/settings path is active.
**6.2 [LOW] ruff: 34 errors in `harness/tests` (19 E702, 13 F401, 1 F841, 1 F541).**
FIX: `ruff check . --fix` (14 auto), hand-fix the E702 semicolons. Add ruff to whatever
gate runs before commits (it exists in dev deps; it just isn't enforced).
**6.3 [LOW] Replay server drifts from the real REST surface.**
`tools/replay/server.mjs`: `/me` is missing (SPA calls it on boot; replay serves the SPA
HTML → JSON parse noise); resume-shape drift was just fixed (`803b98d`) — audit the
remaining routes against `backend/src/routes/*.ts` and stub what the SPA actually calls.
VERIFY: replay boot with devtools console clean.
**6.4 [LOW] `failSession` failures are silently discarded (deliberate).**
`backend/src/routes/sessions.ts:111`: keep the swallow (crash-path best effort) but add a
`console.error` so a storage outage is at least visible server-side.
---
## Rejected reviewer findings (lead judgment)
- *Minimalist:* "`harness/README.md` references `docs/testing.md` which is absent" —
**rejected**: the file exists (`harness/docs/testing.md`).
- *Minimalist:* "bridge `respond()` lets a stale response wedge gate B" — **downgraded**
to 4.6: Pi's own descriptor-id check re-loops the gate; the flaw is real but transient.
- *Skeptic:* "failSession swallow leaves contradictory state" [medium] — **downgraded**
to 6.4: the catch is deliberate crash-path tolerance; only observability is missing.
## What went well (3/3 reviewers found no issues here)
- Pi runtime identity checks, duplicate-start protection, per-session lifecycle
single-flight in the routes.
- `-c` per-subcommand placement and `--json` purity (checked end to end).
- Tool-event sanitization across the backend/client boundary (contract 5).
- Phase folding / ledger mutation core logic and its test coverage.
## Suggested execution order
Fase 1 (crash class, 1h) → 2.1+2.2 (workflow integrity) → 3.1 (SSE generation) →
4.1-4.4 → 5.1-5.3 → 3.2 + Fase 6. Fasi 1-4 are independent of each other and safe to
land as separate commits; 5.1 and 5.2 touch the ledger contract and deserve their own
review pass.
## Environment note
The machine's `codex` CLI was broken (configured default model requires a newer CLI);
upgraded via Homebrew 0.137.0 → 0.144.6 to run the reviewers.
+1 -1
View File
@@ -7,7 +7,7 @@ is not assumed reliable' gains real teeth for the data layer.
import pytest import pytest
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, text
from tht.db.connection import can_create_in_schema, make_engine, ping, writable_tables from tht.db.connection import can_create_in_schema, ping, writable_tables
pytestmark = [pytest.mark.l0] pytestmark = [pytest.mark.l0]
@@ -9,7 +9,6 @@ Run: pytest -m l2 tests/l2/test_memory_save_one_real.py -s (needs .env + VPN +
""" """
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock
import pytest import pytest
+1 -1
View File
@@ -71,7 +71,7 @@ def test_ablazione_session_manual(l2_env, tmp_path):
["THT_DWH_API_KEY", "THT_VEC_API_KEY", "THT_VEC_WRITE_API_KEY"]) ["THT_DWH_API_KEY", "THT_VEC_API_KEY", "THT_VEC_WRITE_API_KEY"])
assert env_ok assert env_ok
assert WORKSPACE.exists() assert WORKSPACE.exists()
print(f"\n[L2 manual] launch: pi --mode rpc (cwd=harness/)") print("\n[L2 manual] launch: pi --mode rpc (cwd=harness/)")
print(f"[L2 manual] /nuova-domanda \"{QUESTION}\"") print(f"[L2 manual] /nuova-domanda \"{QUESTION}\"")
print("[L2 manual] confirm: sql_final.sql present + ledger has value_grounded/" print("[L2 manual] confirm: sql_final.sql present + ledger has value_grounded/"
"concept_formula_approved. Session dir:", tmp_path) "concept_formula_approved. Session dir:", tmp_path)
@@ -25,7 +25,6 @@ def test_ablazione_returns_multiple_columns(l2_env):
non-collapsing aggregation. Requires a built LSH index (tht lsh build).""" non-collapsing aggregation. Requires a built LSH index (tht lsh build)."""
if not WORKSPACE.exists(): if not WORKSPACE.exists():
pytest.skip(f"per-customer workspace repo not found: {WORKSPACE} (clone tht-workspace-psd)") pytest.skip(f"per-customer workspace repo not found: {WORKSPACE} (clone tht-workspace-psd)")
from tht.config import LshConfig
try: try:
from tht.lshindex import load_index, query_index # ported with the lsh build path from tht.lshindex import load_index, query_index # ported with the lsh build path
except ModuleNotFoundError: except ModuleNotFoundError:
-1
View File
@@ -9,7 +9,6 @@ Pure logic, no DB.
import json import json
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from pathlib import Path
import pytest import pytest
+1 -1
View File
@@ -8,7 +8,7 @@ def test_decision_retracted_in_audit_but_marked(tmp_path):
d1 = append_decision( d1 = append_decision(
session, type="table_promoted", subject="phase:4", detail="t1", rationale="r" session, type="table_promoted", subject="phase:4", detail="t1", rationale="r"
) )
d2 = append_decision( append_decision(
session, session,
type="decision_retracted", type="decision_retracted",
subject="phase:4", subject="phase:4",
@@ -6,7 +6,6 @@ These tests pin: LIMIT added when absent, respected when present, never added to
non-query statements, and the truncation-detection contract (limit+1 rows). non-query statements, and the truncation-detection contract (limit+1 rows).
Pure logic, no DB. Pure logic, no DB.
""" """
import pytest
import sqlglot import sqlglot
from tht.execute import _inject_limit from tht.execute import _inject_limit
-2
View File
@@ -6,9 +6,7 @@ surfaces a candidate formula, the reviewer approves or rejects it (recorded via
concept_formula_approved / concept_formula_rejected), and approved formulas are concept_formula_approved / concept_formula_rejected), and approved formulas are
part of the schema-linking artifact. The store is frontmatter-YAML + SQL body. part of the schema-linking artifact. The store is frontmatter-YAML + SQL body.
""" """
from pathlib import Path
import pytest
from tht.evidence.formula_store import ConceptFormula, retrieve_formula, save_formula from tht.evidence.formula_store import ConceptFormula, retrieve_formula, save_formula
@@ -7,7 +7,6 @@ enforced by the skill prose + gate, validated at L2; this test pins the RECORDIN
contract the gate relies on: free text from "Altro" must round-trip into the contract the gate relies on: free text from "Altro" must round-trip into the
decision rationale and survive persistence, never silently discarded. decision rationale and survive persistence, never silently discarded.
""" """
from pathlib import Path
from tht.decisions import append_decision, list_decisions from tht.decisions import append_decision, list_decisions
@@ -9,7 +9,6 @@ from datetime import datetime
from tht.config import EligibilityConfig from tht.config import EligibilityConfig
from tht.mschema.eligibility import classify_all, classify_column, effective_eligibility from tht.mschema.eligibility import classify_all, classify_column, effective_eligibility
from tht.mschema.models import ( from tht.mschema.models import (
Annotations,
ColumnAnnotation, ColumnAnnotation,
ColumnPhysical, ColumnPhysical,
PhysicalSchema, PhysicalSchema,
+14 -7
View File
@@ -17,7 +17,8 @@ def test_effective_decisions_excludes_stale_high_phase(tmp_path):
Sequenza: approva 1, approva 2, reopen a 1, poi table_promoted:4. Sequenza: approva 1, approva 2, reopen a 1, poi table_promoted:4.
current_phase diventa 1 (il reopen). La table_promoted:4 e' di fase 4 > 1 -> stale. current_phase diventa 1 (il reopen). La table_promoted:4 e' di fase 4 > 1 -> stale.
""" """
s = tmp_path / "s"; s.mkdir() s = tmp_path / "s"
s.mkdir()
_d(s, "phase_approved", "phase:1") _d(s, "phase_approved", "phase:1")
_d(s, "phase_approved", "phase:2") _d(s, "phase_approved", "phase:2")
_d(s, "phase_reopened", "phase:1") _d(s, "phase_reopened", "phase:1")
@@ -30,7 +31,8 @@ def test_effective_decisions_excludes_stale_high_phase(tmp_path):
def test_current_phase_after_reopen(tmp_path): def test_current_phase_after_reopen(tmp_path):
"""Il fold su audit-excluding-retracted gestisce correttamente il reopen e le ri-approvazioni.""" """Il fold su audit-excluding-retracted gestisce correttamente il reopen e le ri-approvazioni."""
s = tmp_path / "s"; s.mkdir() s = tmp_path / "s"
s.mkdir()
_d(s, "phase_approved", "phase:1") _d(s, "phase_approved", "phase:1")
_d(s, "phase_approved", "phase:2") _d(s, "phase_approved", "phase:2")
assert current_phase(s) == 3 # dopo 2 approvazioni -> fase 3 assert current_phase(s) == 3 # dopo 2 approvazioni -> fase 3
@@ -42,7 +44,8 @@ def test_current_phase_after_reopen(tmp_path):
def test_retracted_decision_excluded_from_effective(tmp_path): def test_retracted_decision_excluded_from_effective(tmp_path):
"""Una decisione ritirata (decision_retracted) e' esclusa dalla vista effective.""" """Una decisione ritirata (decision_retracted) e' esclusa dalla vista effective."""
s = tmp_path / "s"; s.mkdir() s = tmp_path / "s"
s.mkdir()
d1 = _d(s, "table_promoted", "phase:4", detail="t1") d1 = _d(s, "table_promoted", "phase:4", detail="t1")
_d(s, "decision_retracted", "phase:4", retracts=d1.seq) _d(s, "decision_retracted", "phase:4", retracts=d1.seq)
# senza approvazioni di fase, current_phase=1; table_promoted:4 e' gia' > 1. # senza approvazioni di fase, current_phase=1; table_promoted:4 e' gia' > 1.
@@ -60,13 +63,15 @@ def test_retracted_decision_excluded_from_effective(tmp_path):
def test_current_phase_starts_at_1(tmp_path): def test_current_phase_starts_at_1(tmp_path):
s = tmp_path / "s"; s.mkdir() s = tmp_path / "s"
s.mkdir()
assert current_phase(s) == 1 assert current_phase(s) == 1
def test_current_phase_clamps_at_max_plus_1(tmp_path): def test_current_phase_clamps_at_max_plus_1(tmp_path):
"""Dopo tutte le approvazioni, current_phase = max_phase + 1.""" """Dopo tutte le approvazioni, current_phase = max_phase + 1."""
s = tmp_path / "s"; s.mkdir() s = tmp_path / "s"
s.mkdir()
for n in range(1, 9): for n in range(1, 9):
_d(s, "phase_approved", f"phase:{n}") _d(s, "phase_approved", f"phase:{n}")
assert current_phase(s) == 9 # max_phase(8) + 1 assert current_phase(s) == 9 # max_phase(8) + 1
@@ -74,7 +79,8 @@ def test_current_phase_clamps_at_max_plus_1(tmp_path):
def test_effective_keeps_low_phase_after_high_phase_rollback(tmp_path): def test_effective_keeps_low_phase_after_high_phase_rollback(tmp_path):
"""Rollback a F4 NON invalida le decisioni delle fasi 1-3 (che restano <= current_phase).""" """Rollback a F4 NON invalida le decisioni delle fasi 1-3 (che restano <= current_phase)."""
s = tmp_path / "s"; s.mkdir() s = tmp_path / "s"
s.mkdir()
_d(s, "concept_clarified", "phase:1", detail="x") _d(s, "concept_clarified", "phase:1", detail="x")
_d(s, "question_rewritten", "phase:3", detail="q") _d(s, "question_rewritten", "phase:3", detail="q")
_d(s, "table_promoted", "phase:4", detail="t") _d(s, "table_promoted", "phase:4", detail="t")
@@ -97,7 +103,8 @@ def test_effective_keeps_low_phase_after_high_phase_rollback(tmp_path):
def test_effective_decisions_no_reopen_returns_all_non_retracted(tmp_path): def test_effective_decisions_no_reopen_returns_all_non_retracted(tmp_path):
"""Senza reopen e senza retract, effective = tutte le decisioni (della fase corrente).""" """Senza reopen e senza retract, effective = tutte le decisioni (della fase corrente)."""
s = tmp_path / "s"; s.mkdir() s = tmp_path / "s"
s.mkdir()
_d(s, "concept_clarified", "phase:1", detail="x") _d(s, "concept_clarified", "phase:1", detail="x")
_d(s, "phase_approved", "phase:1") _d(s, "phase_approved", "phase:1")
assert current_phase(s) == 2 assert current_phase(s) == 2
-1
View File
@@ -6,7 +6,6 @@ Pure-logic tests (no DB needed):
""" """
import json import json
import pytest
from tht.execute.limit import inject_limit_offset from tht.execute.limit import inject_limit_offset
+1 -1
View File
@@ -12,7 +12,7 @@ from tht.mschema.models import (
PhysicalSchema, PhysicalSchema,
TablePhysical, TablePhysical,
) )
from tht.sqlcheck import CheckResult, validate_sql from tht.sqlcheck import validate_sql
def _schema() -> PhysicalSchema: def _schema() -> PhysicalSchema:
+12 -7
View File
@@ -1,10 +1,10 @@
from pathlib import Path
from tht.taskdoc import generate_task_doc from tht.taskdoc import generate_task_doc
def test_task_doc_includes_question_and_schema_scope(tmp_path): def test_task_doc_includes_question_and_schema_scope(tmp_path):
s = tmp_path / "sess"; s.mkdir() s = tmp_path / "sess"
s.mkdir()
(s / "question.md").write_text("# Domanda\nQuanti pazienti?\n## Assunzioni\n- a") (s / "question.md").write_text("# Domanda\nQuanti pazienti?\n## Assunzioni\n- a")
(s / "schema_linking.json").write_text( (s / "schema_linking.json").write_text(
'{"question":"q","candidates":[{"kind":"table","name":"pazienti"}],"joins":[],"excluded":[],"open_questions":[]}' '{"question":"q","candidates":[{"kind":"table","name":"pazienti"}],"joins":[],"excluded":[],"open_questions":[]}'
@@ -17,7 +17,8 @@ def test_task_doc_includes_question_and_schema_scope(tmp_path):
def test_task_doc_never_embeds_full_physical_yaml(tmp_path): def test_task_doc_never_embeds_full_physical_yaml(tmp_path):
"""physical.yaml e' fatale per un 35B/<200k (~190k token). Mai incorporarlo.""" """physical.yaml e' fatale per un 35B/<200k (~190k token). Mai incorporarlo."""
s = tmp_path / "sess"; s.mkdir() s = tmp_path / "sess"
s.mkdir()
(s / "question.md").write_text("q") (s / "question.md").write_text("q")
# un physical.yaml enorme fuori dalla sessione (come in ChironeWp3: artifacts/mschema/) # un physical.yaml enorme fuori dalla sessione (come in ChironeWp3: artifacts/mschema/)
(s.parent / "physical.yaml").write_text("x: " + "y" * 800_000) (s.parent / "physical.yaml").write_text("x: " + "y" * 800_000)
@@ -27,7 +28,8 @@ def test_task_doc_never_embeds_full_physical_yaml(tmp_path):
def test_task_doc_byte_budget_enforced_on_normal_input(tmp_path): def test_task_doc_byte_budget_enforced_on_normal_input(tmp_path):
s = tmp_path / "sess"; s.mkdir() s = tmp_path / "sess"
s.mkdir()
(s / "question.md").write_text("q") (s / "question.md").write_text("q")
doc = generate_task_doc(session_dir=s, phase=1) doc = generate_task_doc(session_dir=s, phase=1)
assert doc.byte_budget_ok is True assert doc.byte_budget_ok is True
@@ -35,7 +37,8 @@ def test_task_doc_byte_budget_enforced_on_normal_input(tmp_path):
def test_task_doc_byte_budget_violation_flagged(tmp_path): def test_task_doc_byte_budget_violation_flagged(tmp_path):
"""Se un artefatto di sessione e' enorme (input perverso), byte_budget_ok diventa False.""" """Se un artefatto di sessione e' enorme (input perverso), byte_budget_ok diventa False."""
s = tmp_path / "sess"; s.mkdir() s = tmp_path / "sess"
s.mkdir()
(s / "question.md").write_text("q") (s / "question.md").write_text("q")
(s / "schema_linking.json").write_text("x: " + "y" * 400_000) # ~400KB -> over budget (s / "schema_linking.json").write_text("x: " + "y" * 400_000) # ~400KB -> over budget
doc = generate_task_doc(session_dir=s, phase=7) doc = generate_task_doc(session_dir=s, phase=7)
@@ -43,7 +46,8 @@ def test_task_doc_byte_budget_violation_flagged(tmp_path):
def test_task_doc_carries_phase_header(tmp_path): def test_task_doc_carries_phase_header(tmp_path):
s = tmp_path / "sess"; s.mkdir() s = tmp_path / "sess"
s.mkdir()
(s / "question.md").write_text("q") (s / "question.md").write_text("q")
doc = generate_task_doc(session_dir=s, phase=4) doc = generate_task_doc(session_dir=s, phase=4)
assert "fase 4" in doc.body.lower() or "fase 4" in doc.body assert "fase 4" in doc.body.lower() or "fase 4" in doc.body
@@ -54,7 +58,8 @@ def test_task_doc_excludes_stale_decisions_post_rollback(tmp_path):
Dopo rollback a F4, una sql_approved:7 stale non appare nel brief delle decisioni.""" Dopo rollback a F4, una sql_approved:7 stale non appare nel brief delle decisioni."""
from tht.decisions import append_decision from tht.decisions import append_decision
from tht.phase import current_phase from tht.phase import current_phase
s = tmp_path / "sess"; s.mkdir() s = tmp_path / "sess"
s.mkdir()
(s / "question.md").write_text("q") (s / "question.md").write_text("q")
# simula: lavoro fino a F7, poi rollback a F4 # simula: lavoro fino a F7, poi rollback a F4
append_decision(s, type="phase_approved", subject="phase:1") append_decision(s, type="phase_approved", subject="phase:1")
+12 -6
View File
@@ -2,7 +2,8 @@ from tht.teardown import teardown_to_phase
def test_teardown_to_phase_4_deletes_phase5plus_artifacts(tmp_path): def test_teardown_to_phase_4_deletes_phase5plus_artifacts(tmp_path):
s = tmp_path / "sess"; s.mkdir() s = tmp_path / "sess"
s.mkdir()
(s / "schema_linking.json").write_text("{}") # F4 artifact (s / "schema_linking.json").write_text("{}") # F4 artifact
(s / "cte_plan.json").write_text("[]") # F6 artifact (s / "cte_plan.json").write_text("[]") # F6 artifact
(s / "ctes").mkdir() (s / "ctes").mkdir()
@@ -19,7 +20,8 @@ def test_teardown_to_phase_4_deletes_phase5plus_artifacts(tmp_path):
def test_teardown_to_phase_1_deletes_everything(tmp_path): def test_teardown_to_phase_1_deletes_everything(tmp_path):
"""Rollback a F1 cancella tutti gli artefatti (tutte le fasi > 1).""" """Rollback a F1 cancella tutti gli artefatti (tutte le fasi > 1)."""
s = tmp_path / "sess"; s.mkdir() s = tmp_path / "sess"
s.mkdir()
(s / "question.md").write_text("q") # F3 artifact (s / "question.md").write_text("q") # F3 artifact
(s / "schema_linking.json").write_text("{}") # F4 artifact (s / "schema_linking.json").write_text("{}") # F4 artifact
(s / "cte_plan.json").write_text("[]") # F6 artifact (s / "cte_plan.json").write_text("[]") # F6 artifact
@@ -37,7 +39,8 @@ def test_teardown_to_phase_1_deletes_everything(tmp_path):
def test_teardown_to_phase_7_preserves_phase6_artifacts(tmp_path): def test_teardown_to_phase_7_preserves_phase6_artifacts(tmp_path):
"""Rollback a F7 preserva gli artefatti delle fasi <= 7.""" """Rollback a F7 preserva gli artefatti delle fasi <= 7."""
s = tmp_path / "sess"; s.mkdir() s = tmp_path / "sess"
s.mkdir()
(s / "schema_linking.json").write_text("{}") # F4 artifact (s / "schema_linking.json").write_text("{}") # F4 artifact
(s / "cte_plan.json").write_text("[]") # F6 artifact (s / "cte_plan.json").write_text("[]") # F6 artifact
(s / "ctes").mkdir() (s / "ctes").mkdir()
@@ -52,14 +55,16 @@ def test_teardown_to_phase_7_preserves_phase6_artifacts(tmp_path):
def test_teardown_to_phase_on_empty_session_is_noop(tmp_path): def test_teardown_to_phase_on_empty_session_is_noop(tmp_path):
s = tmp_path / "sess"; s.mkdir() s = tmp_path / "sess"
s.mkdir()
report = teardown_to_phase(s, target_phase=4) report = teardown_to_phase(s, target_phase=4)
assert report.deleted_files == [] assert report.deleted_files == []
def test_teardown_to_phase_nonexistent_artifacts_no_error(tmp_path): def test_teardown_to_phase_nonexistent_artifacts_no_error(tmp_path):
"""Se gli artefatti non esistono (sessione nuova), teardown non fallisce.""" """Se gli artefatti non esistono (sessione nuova), teardown non fallisce."""
s = tmp_path / "sess"; s.mkdir() s = tmp_path / "sess"
s.mkdir()
report = teardown_to_phase(s, target_phase=1) report = teardown_to_phase(s, target_phase=1)
assert report.deleted_files == [] assert report.deleted_files == []
@@ -67,7 +72,8 @@ def test_teardown_to_phase_nonexistent_artifacts_no_error(tmp_path):
def test_teardown_deletes_orphaned_cte_files_not_in_current_plan(tmp_path): def test_teardown_deletes_orphaned_cte_files_not_in_current_plan(tmp_path):
"""Il bug degli orfani CTE: dopo un re-derive con piano diverso, i vecchi .sql """Il bug degli orfani CTE: dopo un re-derive con piano diverso, i vecchi .sql
non piu' nel piano restano su disco e bloccano finalize. teardown li pulisce.""" non piu' nel piano restano su disco e bloccano finalize. teardown li pulisce."""
s = tmp_path / "sess"; s.mkdir() s = tmp_path / "sess"
s.mkdir()
(s / "ctes").mkdir() (s / "ctes").mkdir()
(s / "ctes" / "old_cte.sql").write_text("SELECT 1") # orphan (s / "ctes" / "old_cte.sql").write_text("SELECT 1") # orphan
(s / "ctes" / "another.sql").write_text("SELECT 2") # orphan (s / "ctes" / "another.sql").write_text("SELECT 2") # orphan
-1
View File
@@ -1,4 +1,3 @@
from pathlib import Path
from tht.workspace import load_workspace, WorkspaceError from tht.workspace import load_workspace, WorkspaceError
+11
View File
@@ -334,6 +334,17 @@ const server = createServer(async (req, res) => {
} }
// --- REST stubs the SPA needs to boot ----------------------------------- // --- REST stubs the SPA needs to boot -----------------------------------
// The SPA queries /me at boot (principal for session scoping/admin UI). Falling
// through to the static handler served HTML that apiFetch tried to parse as JSON.
if (method === "GET" && path === "/me") {
return sendJson(res, 200, {
issuer: "replay", subject: "reviewer@replay", displayName: "Replay reviewer", isAdmin: true,
});
}
// Boot-time best-effort warmup: a no-op in replay (no Pi runtime exists).
if (method === "POST" && path === "/runtime/prewarm") {
return sendNoContent(res);
}
if (method === "GET" && path === "/health") { if (method === "GET" && path === "/health") {
return sendJson(res, 200, { ok: true, replay: true, gates: GATES.length }); return sendJson(res, 200, { ok: true, replay: true, gates: GATES.length });
} }