refactor(harness): route workflow persistence through repositories

This commit is contained in:
User
2026-07-16 18:01:29 +02:00
parent 259f021313
commit c1cddaa667
30 changed files with 925 additions and 348 deletions
@@ -67,17 +67,17 @@ def test_memory_command_writes_through_factory_vector_store(monkeypatch):
# configure the workstation-only REST writer key.
cfg = SimpleNamespace(profile="server", embeddings=object(), vector_write_rest=None)
manifest = SimpleNamespace(id="s1")
snapshot = SimpleNamespace(manifest=manifest, decisions=[], artifacts={})
record = MemoryRecord(id="m1", ts=datetime(2026, 1, 1), session_id="s1",
decision_seq=7, type="table_promoted", subject="t",
question_context="q")
monkeypatch.setattr(memory_cmd, "_load_config_or_exit", lambda path: cfg)
monkeypatch.setattr(memory_cmd, "load_session_or_exit", lambda cfg, session: manifest)
monkeypatch.setattr(memory_cmd, "session_dir", lambda *args: None)
monkeypatch.setattr(memory_cmd, "load_snapshot_or_exit", lambda cfg, session: snapshot)
monkeypatch.setattr(memory_cmd, "registry_path", lambda cfg: None)
monkeypatch.setattr("tht.adapters.factory.build_vector_store", lambda cfg, require_write: store)
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder",
lambda cfg: SimpleNamespace(embed_documents=lambda texts: [[0.1]]))
monkeypatch.setattr("tht.memory.promote", lambda *args, **kwargs: None)
monkeypatch.setattr("tht.memory.promote_snapshot", lambda *args, **kwargs: None)
monkeypatch.setattr("tht.memory.load_registry", lambda path: [record])
memory_cmd.save_one_cmd(session="s1", decision=7, json_out=True)
from tht.ports.vector import VectorWriteRecord
@@ -96,14 +96,13 @@ def test_solved_index_writes_through_writer_only_factory_store(monkeypatch):
calls = []
monkeypatch.setattr(memory_cmd, "has_vector_write_rest", lambda cfg: True)
monkeypatch.setattr(memory_cmd, "load_session_or_exit", lambda cfg, session: manifest)
monkeypatch.setattr(memory_cmd, "session_dir", lambda *args: None)
monkeypatch.setattr(memory_cmd, "load_snapshot_or_exit", lambda cfg, session: SimpleNamespace(manifest=manifest, decisions=[], artifacts={}))
monkeypatch.setattr(
"tht.adapters.factory.build_vector_store",
lambda cfg, require_write: calls.append(require_write) or writer_only_store,
)
monkeypatch.setattr("tht.cli.sql_cmd.promoted_tables_for", lambda *args: [])
monkeypatch.setattr("tht.solved.build_solved_record", lambda *args: solved_record)
monkeypatch.setattr("tht.solved.build_solved_snapshot", lambda *args: solved_record)
monkeypatch.setattr(
"tht.solved.save_solved_question",
lambda record, *, store, embedder: int(
+17 -4
View File
@@ -28,15 +28,28 @@ def _walk_to_phase(session, target):
def _configure_command(monkeypatch, sessions):
import tht.cli.decision_cmd as mod
import tht.cli.session_cmd as session_mod
from tht.session.models import SessionManifest, SessionSnapshot
class _Repository:
def get(self, session_id):
return SessionSnapshot(
manifest=SessionManifest(id=session_id, created_at="2026-01-01T00:00:00Z", question="q", database="d", schema="s"),
decisions=list_decisions(sessions / session_id),
)
def append_decisions(self, session_id, decisions):
return append_decisions(sessions / session_id, list(decisions))
class _Cfg:
class paths:
pass
pass
_Cfg.paths.sessions = sessions
repository = _Repository()
monkeypatch.setattr(mod, "_load_config_or_exit", lambda _c: _Cfg())
monkeypatch.setattr(mod, "load_session_or_exit", lambda _cfg, _s: None)
monkeypatch.setattr(mod, "session_dir", lambda _cfg, sid: sessions / sid)
monkeypatch.setattr(mod, "load_snapshot_or_exit", lambda _cfg, sid: repository.get(sid))
monkeypatch.setattr(mod, "session_repository", lambda _cfg: repository)
monkeypatch.setattr(session_mod, "load_snapshot_or_exit", lambda _cfg, sid: repository.get(sid))
def test_add_join_set_rejects_the_whole_batch_when_one_item_is_invalid(tmp_path, monkeypatch):
+18 -1
View File
@@ -25,6 +25,8 @@ def test_retract_drops_last_substantive_decision(tmp_path, monkeypatch):
# stub config + session loading (the command only needs a session dir)
import tht.cli.decision_cmd as mod
import tht.cli.session_cmd as session_mod
from tht.session.models import SessionManifest, SessionSnapshot
class _Cfg:
class paths:
@@ -32,7 +34,22 @@ def test_retract_drops_last_substantive_decision(tmp_path, monkeypatch):
monkeypatch.setattr(mod, "_load_config_or_exit", lambda _c: _Cfg())
monkeypatch.setattr(mod, "load_session_or_exit", lambda _cfg, _s: None)
monkeypatch.setattr(mod, "session_dir", lambda _cfg, sid: tmp_path / sid)
class _Repository:
def get(self, session_id):
return SessionSnapshot(
manifest=SessionManifest(id=session_id, created_at="2026-01-01T00:00:00Z", question="q", database="d", schema="s"),
decisions=list_decisions(tmp_path / session_id),
)
def append_decisions(self, session_id, decisions):
from tht.decisions import append_decisions
return append_decisions(tmp_path / session_id, list(decisions))
repository = _Repository()
monkeypatch.setattr(mod, "load_snapshot_or_exit", lambda _cfg, sid: repository.get(sid))
monkeypatch.setattr(mod, "session_repository", lambda _cfg: repository)
monkeypatch.setattr(session_mod, "load_snapshot_or_exit", lambda _cfg, sid: repository.get(sid))
retract_cmd(session="2026-01-01-000000-x", config=Path("x"))
+5 -1
View File
@@ -3,9 +3,13 @@ from tht.cli.sql_cmd import promoted_columns_for
def test_promoted_columns_for(tmp_path):
sid = "sess1"
sid = "2026-07-16-120000"
sdir = tmp_path / sid
sdir.mkdir(parents=True)
(sdir / "session_manifest.yaml").write_text(
f"id: {sid}\nquestion: q\ndatabase: d\nschema: s\n"
"created_at: 2026-01-01T00:00:00+00:00\nstatus: open\n"
)
(sdir / "schema_linking.json").write_text(json.dumps({
"question": "q",
"candidates": [
@@ -0,0 +1,16 @@
"""Repository-only workflow commands must not recover server state via session_dir."""
import inspect
from tht.cli import memory_cmd, sql_cmd
def test_memory_workflow_commands_do_not_import_or_call_session_dir():
source = inspect.getsource(memory_cmd)
assert "session_dir" not in source
def test_sql_session_commands_read_snapshot_artifacts_not_session_paths():
source = inspect.getsource(sql_cmd)
assert "_session_sql_file" not in source
assert "session_dir" not in source
+7 -2
View File
@@ -16,8 +16,13 @@ def _make_session(tmp_path, current_phase_num: int) -> str:
approving phases 1..N-1 puts the session at phase N."""
import json
s = tmp_path / "sess"
session_id = "2026-07-16-120000"
s = tmp_path / session_id
s.mkdir()
(s / "session_manifest.yaml").write_text(
f"id: {session_id}\nquestion: q\ndatabase: d\nschema: s\n"
"created_at: 2026-01-01T00:00:00+00:00\nstatus: open\n"
)
decisions = [
{"seq": n, "type": "phase_approved", "subject": f"phase:{n}", "ts": "2025-01-01T00:00:00"}
for n in range(1, current_phase_num)
@@ -25,7 +30,7 @@ def _make_session(tmp_path, current_phase_num: int) -> str:
(s / "review_decisions.jsonl").write_text(
"\n".join(json.dumps(d) for d in decisions) + ("\n" if decisions else "")
)
return "sess"
return session_id
class _StubConfig:
+53 -1
View File
@@ -1,10 +1,13 @@
import uuid
import pytest
from tht.config import DatabaseConfig, load_config
from tht.decisions import DecisionInput
from tht.session.filesystem_repository import FilesystemSessionRepository
from tht.session.models import PrincipalContext, SessionManifest
from tht.session.repository import build_session_repository
from tht.session.repository import build_session_repository, resolve_principal
from tht.session.store import SessionError
from tht.session.store import create_session
@@ -100,3 +103,52 @@ def test_filesystem_repository_keeps_preferences_per_principal(tmp_path):
assert alice.get_preferences() == {"model": "glm"}
assert bob.get_preferences() == {}
@pytest.mark.parametrize("session_id", ["2026-01-01-000000-test", "2026-01-01-000000-x", "s1"])
def test_filesystem_repository_reads_safe_legacy_session_ids(tmp_path, session_id):
repository = FilesystemSessionRepository(
tmp_path / "home", "demo", PrincipalContext(issuer="local", subject="alice")
)
directory = repository.root / session_id
directory.mkdir(parents=True)
_manifest(session_id).to_yaml(directory / "session_manifest.yaml")
assert repository.get(session_id).manifest.id == session_id
@pytest.mark.parametrize("session_id", ["..", "a/b", "/absolute", "space id"])
def test_filesystem_repository_rejects_unsafe_legacy_session_ids(tmp_path, session_id):
repository = FilesystemSessionRepository(
tmp_path / "home", "demo", PrincipalContext(issuer="local", subject="alice")
)
with pytest.raises(SessionError):
repository.get(session_id)
def test_postgres_session_storage_requires_trusted_principal_environment(tmp_path, monkeypatch):
config = _config(tmp_path).model_copy(update={"session_storage": {
"type": "postgres_direct",
"connection": _db().model_dump(by_alias=True),
}})
monkeypatch.delenv("THT_PRINCIPAL_ISSUER", raising=False)
monkeypatch.delenv("THT_PRINCIPAL_SUBJECT", raising=False)
with pytest.raises(SessionError, match="THT_PRINCIPAL_ISSUER"):
resolve_principal(config)
def test_postgres_session_storage_uses_only_explicit_trusted_principal(tmp_path, monkeypatch):
config = _config(tmp_path).model_copy(update={"session_storage": {
"type": "postgres_direct",
"connection": _db().model_dump(by_alias=True),
}})
monkeypatch.setenv("THT_PRINCIPAL_ISSUER", "portal")
monkeypatch.setenv("THT_PRINCIPAL_SUBJECT", "alice")
monkeypatch.setenv("THT_PRINCIPAL_DISPLAY_NAME", "Alice")
monkeypatch.setenv("THT_PRINCIPAL_IS_ADMIN", "TRUE")
assert resolve_principal(config) == PrincipalContext(
issuer="portal", subject="alice", display_name="Alice", is_admin=True
)
@@ -0,0 +1,60 @@
import uuid
from tht.decisions import DecisionInput
from tht.phase import current_phase, cte_plan, next_cte
from tht.session.filesystem_repository import FilesystemSessionRepository
from tht.session.models import PrincipalContext, SessionManifest
from tht.session.store import persist_verified_finalization
def _manifest(session_id: str) -> SessionManifest:
return SessionManifest(
id=session_id,
created_at="2026-07-16T10:00:00Z",
question="Which patients had an ablation?",
database="testdb",
schema="public",
)
def test_phase_helpers_fold_the_repository_snapshot_not_a_session_path(tmp_path):
repository = FilesystemSessionRepository(
tmp_path / "home", "demo", PrincipalContext(issuer="local", subject="alice")
)
session_id = str(uuid.uuid4())
repository.create(_manifest(session_id))
repository.write_artifact(session_id, "cte_plan", '["base_patients"]')
repository.append_decisions(
session_id,
[
DecisionInput(type="phase_approved", subject="phase:1"),
DecisionInput(type="phase_auto_approved", subject="phase:2"),
DecisionInput(type="cte_approved", subject="base_patients"),
],
)
snapshot = repository.get(session_id)
assert current_phase(snapshot) == 3
assert cte_plan(snapshot) == ["base_patients"]
assert next_cte(snapshot) is None
def test_verified_finalization_commits_report_evidence_and_status_through_repository(tmp_path):
repository = FilesystemSessionRepository(
tmp_path / "home", "demo", PrincipalContext(issuer="local", subject="alice")
)
session_id = str(uuid.uuid4())
repository.create(_manifest(session_id))
persist_verified_finalization(
repository,
session_id,
validation_report="# Validation\n\nverified against DWH\n",
evidence='[{"source":"review"}]\n',
)
snapshot = repository.get(session_id)
assert snapshot.manifest.status == "finalized"
assert snapshot.artifacts["validation_report"] == "# Validation\n\nverified against DWH\n"
assert snapshot.artifacts["evidence"] == '[{"source":"review"}]\n'
+2 -6
View File
@@ -108,16 +108,12 @@ def test_do_run_offset_zero_path_unchanged(monkeypatch):
def test_preview_session_no_file_resolves_sql_final(monkeypatch, tmp_path, capsys):
"""--session without a positional FILE resolves sql_final.sql via _session_sql_file."""
"""--session without a positional FILE resolves repository SQL text."""
from types import SimpleNamespace
from tht.cli import sql_cmd
sql_file = tmp_path / "sql_final.sql"
sql_file.write_text("SELECT session_resolved")
# Patch _session_sql_file to return our tmp file (no real workspace/DB needed).
monkeypatch.setattr(sql_cmd, "_session_sql_file", lambda cfg, sid: sql_file)
monkeypatch.setattr(sql_cmd, "_session_sql", lambda cfg, sid: "SELECT session_resolved")
captured_sql = {}