diff --git a/devtools/pytest_slot.py b/devtools/pytest_slot.py index 2c2e23cee4..64b21da5f0 100644 --- a/devtools/pytest_slot.py +++ b/devtools/pytest_slot.py @@ -25,10 +25,11 @@ import json import os import shutil +import signal import subprocess import sys import time -from collections.abc import Mapping, Sequence +from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass from pathlib import Path from typing import IO, Any, Final @@ -38,6 +39,7 @@ __all__ = [ "BASETEMP_ROOT_ENV", "INHERITED_ENVIRONMENT_KEYS", + "REAPED_SIGNALS", "PYTEST_GROUP", "PytestSlotUnavailableError", "SlotOutcome", @@ -59,6 +61,11 @@ #: The host group whose parallelism is one. PYTEST_GROUP: Final = "pytest" +#: Signals that end this process while it waits on a task it owns. The task +#: outlives the waiter, and the slot's parallelism is one, so an unreaped task +#: starves every other checkout on the host until someone notices. +REAPED_SIGNALS: Final[tuple[signal.Signals, ...]] = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP) + #: The only keys the ``pueue add`` client inherits. Everything pytest needs #: travels in the launch file, because pueue persists the adder's environment #: into shared state. @@ -212,6 +219,49 @@ def _task_result(status_json: str, task_id: str) -> int: raise PytestSlotUnavailableError(REFUSAL.format(reason=f"pueue task {task_id} ended as {result!r}")) +def _reap_task(task_id: str, *, env: Mapping[str, str], launch_path: Path | None = None) -> None: + """End a task this process owns and drop it from the queue. + + Best effort by construction: the reason we are here is that the waiter is + being killed, so a failing reap must not replace the original cause of + death with its own error. ``kill`` first because ``remove`` refuses a + running task; ``remove`` after because a killed task still occupies the + listing. The launch file carries a resolved environment and must not + outlive the run either way. + """ + + for verb in ("kill", "remove"): + with contextlib.suppress(PytestSlotUnavailableError): + _pueue([verb, task_id], env=env) + if launch_path is not None: + with contextlib.suppress(OSError): + launch_path.unlink(missing_ok=True) + + +@contextlib.contextmanager +def _reaping(task_id: str, *, env: Mapping[str, str], launch_path: Path | None = None) -> Iterator[None]: + """Reap ``task_id`` if this process is signalled or unwound while waiting.""" + + def handle(signal_number: int, frame: object) -> None: + _reap_task(task_id, env=env, launch_path=launch_path) + signal.signal(signal_number, previous.get(signal.Signals(signal_number), signal.SIG_DFL)) + os.kill(os.getpid(), signal_number) + + previous: dict[signal.Signals, Any] = {} + for number in REAPED_SIGNALS: + with contextlib.suppress(ValueError, OSError): + previous[number] = signal.signal(number, handle) + try: + yield + except BaseException: + _reap_task(task_id, env=env, launch_path=launch_path) + raise + finally: + for number, handler in previous.items(): + with contextlib.suppress(ValueError, OSError): + signal.signal(number, handler) + + def _write_launch(path: Path, *, argv: Sequence[str], cwd: str, env: Mapping[str, str], log_path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) document = { @@ -269,9 +319,10 @@ def _queue( raise PytestSlotUnavailableError(REFUSAL.format(reason=f"`pueue add` printed no task id: {added.stdout!r}")) sys.stderr.write(f" waiting for the host pytest slot (pueue task {task_id}, group {PYTEST_GROUP}) ...\n") sys.stderr.flush() - _pueue(["wait", task_id], env=adder) - status = _pueue(["status", "--json"], env=adder) - returncode = _task_result(status.stdout, task_id) + with _reaping(task_id, env=adder, launch_path=launch_path): + _pueue(["wait", task_id], env=adder) + status = _pueue(["status", "--json"], env=adder) + returncode = _task_result(status.stdout, task_id) sys.stderr.write(f" pytest slot released; output: {log_path}\n") sys.stderr.flush() return SlotOutcome(returncode=returncode, slot=f"pueue task {task_id}", log_path=log_path) diff --git a/polylogue/storage/attachment_relink.py b/polylogue/storage/attachment_relink.py index 45ba3ca4a2..6d0791e7d1 100644 --- a/polylogue/storage/attachment_relink.py +++ b/polylogue/storage/attachment_relink.py @@ -43,12 +43,14 @@ from polylogue.pipeline.ids import attachment_message_owner_key, message_owner_resolution from polylogue.pipeline.services.ingest_worker import IngestRecordResult, SessionWritePayload, ingest_record from polylogue.sources.parsers.base import ParsedAttachment, ParsedMessage +from polylogue.sources.parsers.base_support import derive_attachment_provenance from polylogue.storage.runtime.raw.records import RawSessionRecord from polylogue.storage.sqlite.archive_tiers.write import ( _attachment_caption, _attachment_id, _attachment_message_id_maps, _attachment_native_id_values, + _attachment_provenance, _attachment_reference_positions, _attachment_source_url, _duplicate_message_native_ids, @@ -149,6 +151,23 @@ def _message_exists(index_conn: sqlite3.Connection, message_id: str) -> bool: return index_conn.execute("SELECT 1 FROM messages WHERE message_id = ?", (message_id,)).fetchone() is not None +def _persisted_message_provenance( + index_conn: sqlite3.Connection, + message_id: str, +) -> tuple[str | None, str | None]: + """Derive an attachment's provenance from the persisted owning turn. + + An append payload's owning message can already be materialized, so it is + absent from the payload's own message map. Its role is in the index. + """ + + row = index_conn.execute("SELECT role, native_id FROM messages WHERE message_id = ?", (message_id,)).fetchone() + if row is None: + return None, None + direction, producer_ref = derive_attachment_provenance(str(row[0]), row[1] or message_id) + return direction, producer_ref + + def _append_materialized_message_ids( index_conn: sqlite3.Connection, session_id: str, @@ -341,7 +360,7 @@ def _match_session_payload( session_id = payload.session_id messages = payload.parsed_session.messages position_offset = _next_message_position(index_conn, session_id) if payload.append_only else 0 - owner_resolution, by_owner_key, _owning_messages = _attachment_message_id_maps( + owner_resolution, by_owner_key, owning_messages = _attachment_message_id_maps( session_id, messages, position_offset=position_offset, @@ -394,14 +413,23 @@ def _match_session_payload( message_id = resolved_message_ids.get(id(attachment)) if message_id is None: continue + # Provenance goes through the same derivation the writer uses, so a + # record parsed before attachments carried direction relinks with the + # owning turn's direction rather than a null the column refuses. + owning_message = owning_messages.get(message_id) + if owning_message is None and attachment.direction is None: + direction, producer_ref = _persisted_message_provenance(index_conn, message_id) + producer_ref = attachment.producer_ref or producer_ref + else: + direction, producer_ref = _attachment_provenance(attachment, owning_message, resolved_message_id=message_id) recovered[attachment_id] = RelinkableAttachment( attachment_id=attachment_id, session_id=session_id, message_id=message_id, position=attachment_positions[id(attachment)], upload_origin=attachment.upload_origin, - direction=attachment.direction, - producer_ref=attachment.producer_ref, + direction=direction, + producer_ref=producer_ref, source_url=_attachment_source_url(attachment), caption=_attachment_caption(attachment), raw_id=raw_id, diff --git a/polylogue/storage/sqlite/archive_tiers/write.py b/polylogue/storage/sqlite/archive_tiers/write.py index 8ceae30be4..ae0bbc5ffd 100644 --- a/polylogue/storage/sqlite/archive_tiers/write.py +++ b/polylogue/storage/sqlite/archive_tiers/write.py @@ -114,7 +114,13 @@ class ProviderCost: def _write_provider_cost(conn: sqlite3.Connection, session_id: str, model_name: str, cost: ProviderCost) -> None: - """Pass through a provider dollar value without catalog computation.""" + """Pass through a provider dollar value without catalog computation. + + The value is one exact dollar total for the whole session, so it lives on + exactly one model row. A merge-append that switches models carries the + total to the new row; leaving it on the superseded one would double-count + it in every sum over the session. + """ if not isinstance(cost, ProviderCost): raise TypeError("provider cost writes require ProviderCost") conn.execute( @@ -122,6 +128,11 @@ def _write_provider_cost(conn: sqlite3.Connection, session_id: str, model_name: WHERE session_id = ? AND model_name = ?""", (cost.value, session_id, model_name), ) + conn.execute( + """UPDATE session_model_usage SET provider_cost_usd = NULL + WHERE session_id = ? AND model_name <> ?""", + (session_id, model_name), + ) logger = get_logger(__name__) diff --git a/tests/unit/cli/test_embed_activation.py b/tests/unit/cli/test_embed_activation.py index bd2133361f..657727594e 100644 --- a/tests/unit/cli/test_embed_activation.py +++ b/tests/unit/cli/test_embed_activation.py @@ -36,6 +36,8 @@ message_window_for_cost, read_pending_message_count, ) +from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_database +from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier # --------------------------------------------------------------------------- # Splicer @@ -413,7 +415,7 @@ def test_backfill_runs_against_stub_provider( ) index_db = tmp_path / "index.db" - sqlite3.connect(index_db).close() + initialize_archive_database(index_db, ArchiveTier.INDEX) pending = [ PendingSession(session_id="conv-1", title="A", message_count=2), PendingSession(session_id="conv-2", title="B", message_count=2), @@ -457,7 +459,7 @@ def test_backfill_passes_bounded_window_options( fake_select = MagicMock(return_value=[]) fake_preflight = MagicMock(return_value=report) index_db = tmp_path / "index.db" - sqlite3.connect(index_db).close() + initialize_archive_database(index_db, ArchiveTier.INDEX) with ( patch("polylogue.cli.commands.embed._build_preflight_report", fake_preflight), patch( @@ -497,7 +499,7 @@ def test_backfill_routes_archive_to_materializer( ) index_db = tmp_path / "index.db" - sqlite3.connect(index_db).close() + initialize_archive_database(index_db, ArchiveTier.INDEX) pending = [PendingSession(session_id="codex-session:v1", title="v1", message_count=2)] fake_provider = MagicMock() fake_embed = MagicMock( @@ -545,7 +547,7 @@ def test_backfill_json_outputs_structured_result( ) index_db = tmp_path / "index.db" - sqlite3.connect(index_db).close() + initialize_archive_database(index_db, ArchiveTier.INDEX) pending = [PendingSession(session_id="codex-session:v1", title="v1", message_count=2)] with ( _patch_preflight(_make_report(pending_sessions=1, pending_messages=2, max_messages=2)), @@ -631,7 +633,7 @@ def test_backfill_stop_after_seconds_stops_before_next_session( ) index_db = tmp_path / "index.db" - sqlite3.connect(index_db).close() + initialize_archive_database(index_db, ArchiveTier.INDEX) # Event-driven fake clock: scripted read sequences broke twice (a # StopIteration when the command read the clock an extra time, then a # second embed when an extra read consumed the past-deadline value). @@ -692,7 +694,7 @@ def test_backfill_max_errors_stops_after_provider_error( ) index_db = tmp_path / "index.db" - sqlite3.connect(index_db).close() + initialize_archive_database(index_db, ArchiveTier.INDEX) fake_embed = MagicMock( side_effect=[ EmbedSessionOutcome(status="error", session_id="conv-1", error="provider 429"), @@ -740,7 +742,7 @@ def test_backfill_run_cost_cap_stops_before_provider_call( ) index_db = tmp_path / "index.db" - sqlite3.connect(index_db).close() + initialize_archive_database(index_db, ArchiveTier.INDEX) fake_embed = MagicMock( side_effect=[ EmbedSessionOutcome(status="embedded", session_id="conv-1", embedded_message_count=2), diff --git a/tests/unit/core/test_insight_readiness.py b/tests/unit/core/test_insight_readiness.py index de0a11cac6..cd2f44561d 100644 --- a/tests/unit/core/test_insight_readiness.py +++ b/tests/unit/core/test_insight_readiness.py @@ -206,6 +206,7 @@ async def test_insight_readiness_report_marks_missing_insight_tables(tmp_path: P tool_name TEXT, tool_result_exit_code INTEGER, tool_result_is_error INTEGER, + tool_outcome TEXT, search_text TEXT ); INSERT INTO sessions (session_id, parent_session_id, source_name, sort_key, updated_at) diff --git a/tests/unit/core/test_synthetic_wire_support.py b/tests/unit/core/test_synthetic_wire_support.py index 90565f1adf..6d734d731e 100644 --- a/tests/unit/core/test_synthetic_wire_support.py +++ b/tests/unit/core/test_synthetic_wire_support.py @@ -167,7 +167,7 @@ def drop_first_coverage_witness( ) monkeypatch.setattr(dispatch_module, "parse_payload", drop_first_coverage_witness) - receipt = wire_formats.build_wire_support_receipt(registry=SchemaRegistry()) + receipt = wire_formats.build_wire_support_receipt(registry=SchemaRegistry(), providers=("chatgpt",)) entry = next(item for item in receipt.entries if item.provider == "chatgpt") dropped = next(witness for witness in entry.parser_witnesses if witness.index == 0) @@ -751,7 +751,7 @@ def fail_baseline( ) monkeypatch.setattr(dispatch_module, "parse_payload", fail_baseline) - receipt = wire_formats.build_wire_support_receipt(registry=SchemaRegistry()) + receipt = wire_formats.build_wire_support_receipt(registry=SchemaRegistry(), providers=("chatgpt",)) entry = next(item for item in receipt.entries if item.provider == "chatgpt") baseline = next(item for item in entry.parser_witnesses if item.artifact_kind == "baseline") @@ -775,7 +775,7 @@ def fail_chatgpt_baseline(self: SchemaValidator, payload: JSONValue) -> validato return original_validate(self, payload) monkeypatch.setattr(validator_module.SchemaValidator, "validate", fail_chatgpt_baseline) - receipt = wire_formats.build_wire_support_receipt(registry=SchemaRegistry()) + receipt = wire_formats.build_wire_support_receipt(registry=SchemaRegistry(), providers=("chatgpt",)) entry = next(item for item in receipt.entries if item.provider == "chatgpt") baseline = next(item for item in entry.parser_witnesses if item.artifact_kind == "baseline") @@ -892,11 +892,19 @@ def test_antigravity_metadata_only_source_has_no_language_server_session(tmp_pat assert sessions == [] +#: Building the receipt runs the full synthetic corpus and parser for every +#: catalogued package element, so a test that asserts a receipt *changed* +#: names the providers whose entries carry the change rather than paying for +#: the whole catalog. Catalog-wide construction stays covered by +#: ``test_support_receipt_is_deterministic``. +_COVERAGE_PROVIDERS = ("chatgpt", "codex") + + def test_construct_handler_removal_changes_coverage_receipt(monkeypatch: pytest.MonkeyPatch) -> None: - before = wire_formats.build_wire_support_receipt(registry=SchemaRegistry()) + before = wire_formats.build_wire_support_receipt(registry=SchemaRegistry(), providers=_COVERAGE_PROVIDERS) monkeypatch.delitem(SCHEMA_CONSTRUCT_HANDLERS, "array") - after = wire_formats.build_wire_support_receipt(registry=SchemaRegistry()) + after = wire_formats.build_wire_support_receipt(registry=SchemaRegistry(), providers=_COVERAGE_PROVIDERS) assert before.to_dict() != after.to_dict() assert not after.complete @@ -1032,7 +1040,7 @@ def __init__( super().__init__(schema, strict=strict, provider=provider) monkeypatch.setattr(validator_module, "SchemaValidator", CapturingValidator) - receipt = wire_formats.build_wire_support_receipt(registry=registry) + receipt = wire_formats.build_wire_support_receipt(registry=registry, providers=("chatgpt",)) entry = next(item for item in receipt.entries if item.provider == "chatgpt") assert entry.schema_valid is True @@ -1045,7 +1053,7 @@ def __init__( def test_claude_code_route_only_waives_unrepresentable_nested_content() -> None: - receipt = wire_formats.build_wire_support_receipt(registry=SchemaRegistry()) + receipt = wire_formats.build_wire_support_receipt(registry=SchemaRegistry(), providers=("claude-code",)) entry = next(item for item in receipt.entries if item.provider == "claude-code") assert entry.construct_coverage is not None @@ -1147,10 +1155,10 @@ def test_missing_required_and_additional_property_evidence_makes_receipt_incompl def test_removed_provider_route_changes_explicit_support_receipt(monkeypatch: pytest.MonkeyPatch) -> None: - before = wire_formats.build_wire_support_receipt(registry=SchemaRegistry()) + before = wire_formats.build_wire_support_receipt(registry=SchemaRegistry(), providers=_COVERAGE_PROVIDERS) monkeypatch.delitem(wire_formats.PROVIDER_WIRE_ROUTES, "codex") - after = wire_formats.build_wire_support_receipt(registry=SchemaRegistry()) + after = wire_formats.build_wire_support_receipt(registry=SchemaRegistry(), providers=_COVERAGE_PROVIDERS) assert before.to_dict() != after.to_dict() assert after.missing_routes == ("codex",) diff --git a/tests/unit/daemon/test_web_shell_reader.py b/tests/unit/daemon/test_web_shell_reader.py index a3be0ec703..8b8944744d 100644 --- a/tests/unit/daemon/test_web_shell_reader.py +++ b/tests/unit/daemon/test_web_shell_reader.py @@ -167,7 +167,9 @@ def test_js_declares_copy_message_anchor(self) -> None: def test_assembled_html_contains_message_anchor_markup(self, workspace_env: dict[str, Path]) -> None: """The full assembled shell HTML must contain msg-anchor-link.""" with _running_server(workspace_env, seeded=False) as (_, base_url): - _, _, body = _get_text(base_url, "/") + # The assembled shell answers on the workspace routes; the root + # serves the typed WebUI. + _, _, body = _get_text(base_url, "/w/stack") assert "msg-anchor-link" in body assert "msg-anchor-target" in body @@ -222,7 +224,9 @@ def test_text_fold_renders_fold_bar_with_metadata(self) -> None: def test_assembled_html_contains_text_fold_markup(self, workspace_env: dict[str, Path]) -> None: """The assembled shell HTML must contain text-fold CSS classes.""" with _running_server(workspace_env, seeded=False) as (_, base_url): - _, _, body = _get_text(base_url, "/") + # The assembled shell answers on the workspace routes; the root + # serves the typed WebUI. + _, _, body = _get_text(base_url, "/w/stack") assert "msg-text-fold" in body @@ -268,7 +272,9 @@ def test_density_defaults_to_comfortable(self) -> None: def test_assembled_html_contains_density_toggle_markup(self, workspace_env: dict[str, Path]) -> None: """The assembled shell HTML must contain the density-toggle class.""" with _running_server(workspace_env, seeded=False) as (_, base_url): - _, _, body = _get_text(base_url, "/") + # The assembled shell answers on the workspace routes; the root + # serves the typed WebUI. + _, _, body = _get_text(base_url, "/w/stack") assert "density-toggle" in body @@ -304,7 +310,9 @@ def test_double_g_timer_resets_on_single_wait(self) -> None: def test_assembled_footer_lists_new_shortcuts(self, workspace_env: dict[str, Path]) -> None: """The footer hint strip must include g g and G.""" with _running_server(workspace_env, seeded=False) as (_, base_url): - _, _, body = _get_text(base_url, "/") + # The assembled shell answers on the workspace routes; the root + # serves the typed WebUI. + _, _, body = _get_text(base_url, "/w/stack") assert "g g" in body assert "top" in body assert "bottom" in body diff --git a/tests/unit/devtools/test_deployment_smoke.py b/tests/unit/devtools/test_deployment_smoke.py index c63e7a0948..8545ea1f37 100644 --- a/tests/unit/devtools/test_deployment_smoke.py +++ b/tests/unit/devtools/test_deployment_smoke.py @@ -11,6 +11,8 @@ import pytest from devtools import deployment_smoke +from polylogue.storage.sqlite.archive_tiers import ARCHIVE_VERSION_BY_TIER +from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier def _create_browser_source_db( @@ -116,6 +118,9 @@ def _create_browser_index_db( message_count, ), ) + # The probe opens the index through the tier-guarded read profile, so + # a hand-built index must declare the version that profile expects. + conn.execute(f"PRAGMA user_version = {ARCHIVE_VERSION_BY_TIER[ArchiveTier.INDEX]}") class _FakeResponse: @@ -561,6 +566,9 @@ def test_deployment_smoke_reports_latest_browser_capture_missing_index_row(tmp_p ) """ ) + # The probe opens the index through the tier-guarded read profile, so + # a hand-built index must declare the version that profile expects. + conn.execute(f"PRAGMA user_version = {ARCHIVE_VERSION_BY_TIER[ArchiveTier.INDEX]}") probe = deployment_smoke._probe_browser_capture_archive(archive_root=tmp_path) diff --git a/tests/unit/devtools/test_pytest_slot.py b/tests/unit/devtools/test_pytest_slot.py index 6cdaa534be..bfe94f6137 100644 --- a/tests/unit/devtools/test_pytest_slot.py +++ b/tests/unit/devtools/test_pytest_slot.py @@ -13,6 +13,7 @@ import json import os +import signal import stat import sys from pathlib import Path @@ -294,3 +295,76 @@ def test_the_leaked_cloud_basetemp_sentinel_is_declined(tmp_path: Path, monkeypa sentinel = cloud_sentinels.CLOUD_SENTINELS[BASETEMP_ROOT_ENV] assert basetemp_root({BASETEMP_ROOT_ENV: sentinel}, root=tmp_path) == tmp_path / ".cache" / "verify" + + +#: A ``pueue`` whose ``wait`` kills the process waiting on it, the way a +#: session or a wrapper being killed leaves a queued task with no waiter. +FAKE_PUEUE_KILLS_ITS_WAITER = """#!/usr/bin/env python3 +import json, os, signal, sys, time + +with open(sys.argv[0] + ".calls.jsonl", "a", encoding="utf-8") as handle: + handle.write(json.dumps({"argv": sys.argv[1:]}) + "\\n") + +command = sys.argv[1] if len(sys.argv) > 1 else "" +if command == "add": + print("11") +elif command == "wait": + os.kill(os.getppid(), signal.SIGTERM) + time.sleep(2) +sys.exit(0) +""" + +_WAITER = """ +import os, sys +sys.path.insert(0, {repo!r}) +from devtools.pytest_slot import run_pytest + +run_pytest( + [sys.executable, "-c", "pass"], + cwd={cwd!r}, + env={{"PATH": os.environ["PATH"], "HOME": os.environ["HOME"]}}, + root={root!r}, + label="polylogue:test:signalled", +) +""" + + +def test_a_killed_waiter_reaps_the_task_it_queued(tmp_path: Path) -> None: + """A task outlives its waiter, and the slot's parallelism is one. + + Anti-vacuity: dropping the ``_reaping`` context leaves the recorded calls + at ``add``/``wait`` -- the task stays queued with nothing left to wait on + it, which is exactly the starvation this reap exists to prevent. + """ + import subprocess + + directory = tmp_path / "fakebin" + directory.mkdir() + script = directory / "pueue" + script.write_text(FAKE_PUEUE_KILLS_ITS_WAITER, encoding="utf-8") + script.chmod(script.stat().st_mode | stat.S_IXUSR) + record = Path(str(script) + ".calls.jsonl") + repo = str(Path(pytest_slot.__file__).resolve().parents[1]) + + completed = subprocess.run( + [ + sys.executable, + "-c", + _WAITER.format(repo=repo, cwd=str(tmp_path), root=str(tmp_path)), + ], + env={ + "PATH": f"{directory}{os.pathsep}{os.environ['PATH']}", + "HOME": os.environ.get("HOME", "/home/nobody"), + }, + capture_output=True, + text=True, + timeout=60, + ) + + assert completed.returncode == -int(signal.SIGTERM), completed.stderr + verbs = [call["argv"][0] for call in _calls(record)] + assert verbs == ["add", "wait", "kill", "remove"], verbs + leftover = list((tmp_path / "verify").glob("pytest-slot-*.json")) + list( + (tmp_path / ".cache" / "verify").glob("pytest-slot-*.json") + ) + assert leftover == [], "the launch file carries a resolved environment and must not survive the reap" diff --git a/tests/unit/storage/test_session_insight_status_descriptors.py b/tests/unit/storage/test_session_insight_status_descriptors.py index 1d1721bc04..8a3eaad0ef 100644 --- a/tests/unit/storage/test_session_insight_status_descriptors.py +++ b/tests/unit/storage/test_session_insight_status_descriptors.py @@ -246,6 +246,7 @@ def test_profile_repair_candidates_ignore_hot_recent_sources() -> None: tool_name TEXT, tool_result_exit_code INTEGER, tool_result_is_error INTEGER, + tool_outcome TEXT, search_text TEXT ); CREATE TABLE session_profiles ( @@ -297,6 +298,7 @@ def test_session_insight_status_requires_latency_rows_for_ready_profiles() -> No tool_name TEXT, tool_result_exit_code INTEGER, tool_result_is_error INTEGER, + tool_outcome TEXT, search_text TEXT ); CREATE TABLE session_profiles ( @@ -412,6 +414,7 @@ def test_status_treats_run_projection_materialization_as_optional_cache() -> Non tool_name TEXT, tool_result_exit_code INTEGER, tool_result_is_error INTEGER, + tool_outcome TEXT, search_text TEXT ); CREATE TABLE session_runs (session_id TEXT NOT NULL); @@ -525,6 +528,7 @@ def test_status_tracks_work_and_phase_staleness_from_materialization_ledger() -> tool_name TEXT, tool_result_exit_code INTEGER, tool_result_is_error INTEGER, + tool_outcome TEXT, search_text TEXT ); CREATE TABLE session_profiles ( @@ -619,6 +623,7 @@ async def test_status_sync_and_async_match_when_product_tables_are_absent(tmp_pa tool_name TEXT, tool_result_exit_code INTEGER, tool_result_is_error INTEGER, + tool_outcome TEXT, search_text TEXT ); INSERT INTO sessions (session_id, parent_session_id, sort_key_ms, updated_at_ms) @@ -672,6 +677,7 @@ async def test_lightweight_status_sync_and_async_match_with_freshness_tables(tmp tool_name TEXT, tool_result_exit_code INTEGER, tool_result_is_error INTEGER, + tool_outcome TEXT, search_text TEXT ); CREATE TABLE session_profiles ( diff --git a/tests/unit/storage/test_wal_journal_size_limit.py b/tests/unit/storage/test_wal_journal_size_limit.py index a10d21e978..ca5d965529 100644 --- a/tests/unit/storage/test_wal_journal_size_limit.py +++ b/tests/unit/storage/test_wal_journal_size_limit.py @@ -77,7 +77,7 @@ def test_daemon_write_profile_uses_bounded_cache_and_mmap() -> None: def test_open_connection_applies_journal_size_limit_on_disk(tmp_path: Path) -> None: """The factory ``open_connection`` propagates the cap to SQLite.""" - db_path = tmp_path / "index.db" + db_path = tmp_path / "journal-pragmas.db" conn = open_connection(db_path) try: limit = _pragma_int(conn, "journal_size_limit") @@ -90,7 +90,7 @@ def test_open_connection_applies_journal_size_limit_on_disk(tmp_path: Path) -> N def test_open_readonly_connection_applies_query_only(tmp_path: Path) -> None: """The read factory turns on query_only at the SQL parser level.""" - db_path = tmp_path / "index.db" + db_path = tmp_path / "journal-pragmas.db" # Bootstrap a tiny schema so the read connection has something to attach to. seed = sqlite3.connect(str(db_path)) try: @@ -110,7 +110,7 @@ def test_open_readonly_connection_applies_query_only(tmp_path: Path) -> None: def test_open_daemon_connection_applies_bounded_cache_and_mmap(tmp_path: Path) -> None: - db_path = tmp_path / "ops.db" + db_path = tmp_path / "daemon-pragmas.db" conn = open_daemon_connection(db_path) try: assert _pragma_int(conn, "cache_size") == -DAEMON_WRITE_CACHE_SIZE_KIB @@ -133,7 +133,7 @@ def test_wal_file_truncates_back_to_size_limit_after_checkpoint(tmp_path: Path) TRUNCATE checkpoint at the end is the only mechanism that can shrink the WAL, and that's exactly what the limit enforces. """ - db_path = tmp_path / "index.db" + db_path = tmp_path / "journal-pragmas.db" writer = open_connection(db_path) try: writer.execute("CREATE TABLE blob (id INTEGER PRIMARY KEY, payload BLOB)") @@ -191,7 +191,7 @@ def test_wal_stays_bounded_under_concurrent_reader_and_writer(tmp_path: Path) -> """ import threading - db_path = tmp_path / "index.db" + db_path = tmp_path / "journal-pragmas.db" # Seed the schema on a separate connection so the worker threads # don't race the table creation. diff --git a/tests/visual/test_evidence_cockpit_ia_smoke.py b/tests/visual/test_evidence_cockpit_ia_smoke.py index 96e4e5b528..ec1d57c493 100644 --- a/tests/visual/test_evidence_cockpit_ia_smoke.py +++ b/tests/visual/test_evidence_cockpit_ia_smoke.py @@ -53,7 +53,9 @@ def test_verb_nav_and_ia_contract_in_served_shell(reader_workspace: ReaderWorkspace) -> None: with running_reader_server(reader_workspace) as (_, base_url): - status, content_type, body = get_text(base_url, "/") + # The interpolated shell this test reads answers on the workspace + # routes; the root serves the typed WebUI. + status, content_type, body = get_text(base_url, "/w/stack") assert status == 200 assert "text/html" in content_type diff --git a/tests/visual/test_reader_attachments.py b/tests/visual/test_reader_attachments.py index 09aebefc5d..adf96201d1 100644 --- a/tests/visual/test_reader_attachments.py +++ b/tests/visual/test_reader_attachments.py @@ -92,7 +92,9 @@ class _Stub: def test_reader_attachment_surface_contract(reader_workspace: ReaderWorkspace, tmp_path: Path) -> None: with running_reader_server(reader_workspace) as (_, base_url): seed_reader_attachments(reader_workspace) - status, content_type, body = get_text(base_url, "/") + # The interpolated shell this test reads answers on the workspace + # routes; the root serves the typed WebUI. + status, content_type, body = get_text(base_url, "/w/stack") conv_payload = get_json(base_url, f"/api/sessions/{READER_C1}") per_conv = get_json(base_url, f"/api/sessions/{READER_C1}/attachments") library = get_json(base_url, "/api/attachments?limit=100") @@ -268,7 +270,9 @@ def test_raw_html_attachment_renders_no_inline_script( with running_reader_server(reader_workspace) as (_, base_url): seed_reader_attachments(reader_workspace) - _status, _ctype, shell_body = get_text(base_url, "/") + # The interpolated shell this test reads answers on the workspace + # routes; the root serves the typed WebUI. + _status, _ctype, shell_body = get_text(base_url, "/w/stack") _ls, _lc, library_body = get_text(base_url, "/a") payload = get_json(base_url, "/api/attachments?limit=200") diff --git a/tests/visual/test_reader_dom_smoke.py b/tests/visual/test_reader_dom_smoke.py index 3b0dfc1dee..13907e3f5a 100644 --- a/tests/visual/test_reader_dom_smoke.py +++ b/tests/visual/test_reader_dom_smoke.py @@ -16,6 +16,7 @@ assert_no_private_paths, get_json, get_text, + parse_dom, running_reader_server, seed_reader_assertion_claims, write_evidence_manifest, @@ -30,6 +31,88 @@ def _send_json(base_url: str, method: str, path: str, payload: dict[str, object] return resp.status, json.loads(resp.read()) +def test_reader_search_shell_dom_evidence(reader_workspace: ReaderWorkspace, tmp_path: Path) -> None: + with running_reader_server(reader_workspace) as (_, base_url): + # The interpolated shell answers on the workspace routes; the root + # serves the typed WebUI. + status, content_type, body = get_text(base_url, "/w/stack") + + assert status == 200 + assert "text/html" in content_type + assert len(body) > 20_000 + assert "https://cdn" not in body + assert_no_private_paths(body, context="reader shell HTML") + + dom = parse_dom(body) + expected_ids = { + "app", + "status-strip", + "status-dot", + "status-browser-capture", + "sidebar", + "search", + "facet-bar", + "conv-list", + "main", + "conv-header", + "msg-list", + "inspector", + "inspector-tabs", + "workspace-toolbar", + "workspace-mode-switcher", + "workspace-save-btn", + "workspace-restore-select", + "workspace-create-recall-pack-btn", + "footer", + "help-overlay", + } + assert expected_ids <= dom.ids + assert dom.meta_viewport is True + assert dom.scripts == 1 + assert dom.styles == 1 + for phrase in ( + "Select a session", + "Keyboard Shortcuts", + "Focus search", + "Local", + "/api/user/marks", + "/api/user/annotations", + "toggleMark", + "saveAnnotation", + "No annotations on this session", + "Save current view", + "Saved Views", + "Save workspace", + "Restore workspace", + "Recall pack", + "/api/user/workspaces", + "/api/user/recall-packs", + "/api/stack", + "/api/compare", + ): + assert phrase in body + + checks = { + "status": status, + "content_type": content_type, + "html_bytes": len(body.encode()), + "required_ids": sorted(expected_ids), + "script_tags": dom.scripts, + "style_tags": dom.styles, + "viewport_meta": dom.meta_viewport, + "private_path_safe": True, + "runtime_cdn_free": True, + } + manifest = write_evidence_manifest( + tmp_path / "reader-search-dom-evidence.json", + artifact_id="polylogue.local_reader.search", + route="/w/stack", + fixture_id="reader-visual-synthetic-v1", + checks=checks, + ) + assert manifest["evidence_kind"] == "browserless-dom" + + def test_reader_stack_workspace_dom_evidence(reader_workspace: ReaderWorkspace, tmp_path: Path) -> None: with running_reader_server(reader_workspace) as (_, base_url): status, content_type, shell = get_text( diff --git a/tests/visual/test_reader_paste_spans.py b/tests/visual/test_reader_paste_spans.py index aa4e8d9e1b..7060a2d08f 100644 --- a/tests/visual/test_reader_paste_spans.py +++ b/tests/visual/test_reader_paste_spans.py @@ -39,7 +39,9 @@ def test_reader_paste_spans_contract(reader_workspace: ReaderWorkspace, tmp_path: Path) -> None: with running_reader_server(reader_workspace) as (_, base_url): seed_reader_diff_paste(reader_workspace) - status, content_type, body = get_text(base_url, "/") + # The interpolated shell this test reads answers on the workspace + # routes; the root serves the typed WebUI. + status, content_type, body = get_text(base_url, "/w/stack") # Session envelope must carry ``paste_spans``. conv_payload = get_json(base_url, f"/api/sessions/{READER_C3}") assert isinstance(conv_payload, dict) diff --git a/tests/visual/test_reader_semantic_cards.py b/tests/visual/test_reader_semantic_cards.py index 2acc727b5d..e8782f85b2 100644 --- a/tests/visual/test_reader_semantic_cards.py +++ b/tests/visual/test_reader_semantic_cards.py @@ -156,7 +156,9 @@ def test_semantic_card_web_json_contract(_semantic_card_session: tuple[str, dict def test_semantic_card_web_dom_shape_contract(reader_workspace: ReaderWorkspace, tmp_path: Path) -> None: with running_reader_server(reader_workspace) as (_, base_url): - status, content_type, body = get_text(base_url, "/") + # The interpolated shell this test reads answers on the workspace + # routes; the root serves the typed WebUI. + status, content_type, body = get_text(base_url, "/w/stack") assert status == 200 assert "text/html" in content_type diff --git a/tests/visual/test_route_state_interaction_smoke.py b/tests/visual/test_route_state_interaction_smoke.py index e99f7fc571..986b22832c 100644 --- a/tests/visual/test_route_state_interaction_smoke.py +++ b/tests/visual/test_route_state_interaction_smoke.py @@ -72,7 +72,9 @@ def test_truthful_route_state_contract_in_served_shell( reader_workspace: ReaderWorkspace, ) -> None: with running_reader_server(reader_workspace) as (_, base_url): - status, content_type, body = get_text(base_url, "/") + # The interpolated shell whose route-state behaviour this test pins + # answers on the workspace routes; the root serves the typed WebUI. + status, content_type, body = get_text(base_url, "/w/stack") assert status == 200 assert "text/html" in content_type