refactor(harness): route workflow persistence through repositories
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user