From 1120cd92187b43238a88d66935b240a9f3609309 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:33:41 +1000 Subject: [PATCH 01/29] feat(web): add plainweave[web] extra and lazy 'plainweave web' CLI --- pyproject.toml | 11 ++++ src/plainweave/cli.py | 3 + src/plainweave/web/__init__.py | 1 + src/plainweave/web/server.py | 59 ++++++++++++++++++ src/plainweave/web/static/.gitkeep | 0 src/plainweave/web/templates/.gitkeep | 0 tests/web/__init__.py | 0 tests/web/test_server.py | 33 ++++++++++ uv.lock | 90 ++++++++++++++++++++++++++- 9 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 src/plainweave/web/__init__.py create mode 100644 src/plainweave/web/server.py create mode 100644 src/plainweave/web/static/.gitkeep create mode 100644 src/plainweave/web/templates/.gitkeep create mode 100644 tests/web/__init__.py create mode 100644 tests/web/test_server.py diff --git a/pyproject.toml b/pyproject.toml index eb54599..825c902 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,12 +43,23 @@ Homepage = "https://github.com/foundryside-dev/plainweave" Repository = "https://github.com/foundryside-dev/plainweave" Issues = "https://github.com/foundryside-dev/plainweave/issues" +[project.optional-dependencies] +web = [ + "starlette>=0.37", + "uvicorn>=0.30", + "jinja2>=3.1", +] + [tool.hatch.version] path = "src/plainweave/_version.py" [tool.hatch.build.targets.wheel] packages = ["src/plainweave"] +[tool.hatch.build.targets.wheel.force-include] +"src/plainweave/web/templates" = "plainweave/web/templates" +"src/plainweave/web/static" = "plainweave/web/static" + [tool.ruff] line-length = 120 target-version = "py312" diff --git a/src/plainweave/cli.py b/src/plainweave/cli.py index 1a30a95..66dbc9b 100644 --- a/src/plainweave/cli.py +++ b/src/plainweave/cli.py @@ -16,6 +16,9 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--version", action="store_true", help="Print the Plainweave version and exit.") subparsers = parser.add_subparsers(dest="command") register_commands(subparsers) + from plainweave.web.server import add_web_subcommand # local import keeps web optional + + add_web_subcommand(subparsers) return parser diff --git a/src/plainweave/web/__init__.py b/src/plainweave/web/__init__.py new file mode 100644 index 0000000..82037cb --- /dev/null +++ b/src/plainweave/web/__init__.py @@ -0,0 +1 @@ +"""Optional operator-facing web tier (the plainweave[web] extra).""" diff --git a/src/plainweave/web/server.py b/src/plainweave/web/server.py new file mode 100644 index 0000000..0cc3a01 --- /dev/null +++ b/src/plainweave/web/server.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +WEB_EXTRA_HINT = ( + "The web UI needs the optional 'web' extra. Install it with:\n" + " pip install plainweave[web]\n" + "(or: uv pip install 'plainweave[web]')" +) + + +def add_web_subcommand(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: + parser = subparsers.add_parser("web", help="Run the operator-facing web UI (needs plainweave[web]).") + parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1).") + parser.add_argument("--port", type=int, default=8765, help="Bind port (default: 8765).") + parser.add_argument("--actor", default=None, help="Operator actor id (default: from config / first-run).") + parser.add_argument( + "--no-open", + dest="open_browser", + action="store_false", + help="Do not open a browser on start.", + ) + parser.set_defaults(open_browser=True, handler=_handle) + + +def _handle(args: argparse.Namespace) -> int: + return run_web(host=args.host, port=args.port, actor=args.actor, open_browser=args.open_browser) + + +def run_web(*, host: str, port: int, actor: str | None, open_browser: bool, root: Path | None = None) -> int: + try: + return _serve(host=host, port=port, actor=actor, open_browser=open_browser, root=root) + except ModuleNotFoundError: + print(WEB_EXTRA_HINT) + return 1 + + +def _serve( # pragma: no cover + *, host: str, port: int, actor: str | None, open_browser: bool, root: Path | None = None +) -> int: + # Lazy import: only touches starlette/uvicorn when the extra is installed. + import uvicorn # noqa: PLC0415 + + from plainweave.web.app import create_app # type: ignore[import-untyped] # noqa: PLC0415 + + app = create_app(actor=actor, root=root) + if open_browser: + _open_browser_later(host, port) + uvicorn.run(app, host=host, port=port, log_level="info") + return 0 + + +def _open_browser_later(host: str, port: int) -> None: # pragma: no cover + import threading + import webbrowser + + url = f"http://{host}:{port}/" + threading.Timer(0.8, lambda: webbrowser.open(url)).start() diff --git a/src/plainweave/web/static/.gitkeep b/src/plainweave/web/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/plainweave/web/templates/.gitkeep b/src/plainweave/web/templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/web/__init__.py b/tests/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/web/test_server.py b/tests/web/test_server.py new file mode 100644 index 0000000..0b311ef --- /dev/null +++ b/tests/web/test_server.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import argparse + +import pytest + +from plainweave.web import server + + +def test_web_subcommand_parses_defaults() -> None: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command") + server.add_web_subcommand(sub) + args = parser.parse_args(["web"]) + assert args.command == "web" + assert args.host == "127.0.0.1" + assert args.port == 8765 + assert args.open_browser is True + assert callable(args.handler) + + +def test_run_web_without_starlette_prints_hint( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # Simulate the optional extra being absent. + def boom(**_kwargs: object) -> None: + raise ModuleNotFoundError("No module named 'starlette'") + + monkeypatch.setattr(server, "_serve", boom) + rc = server.run_web(host="127.0.0.1", port=8765, actor=None, open_browser=False) + out = capsys.readouterr().out + assert rc == 1 + assert "pip install plainweave[web]" in out diff --git a/uv.lock b/uv.lock index 9f9ad5b..bee3621 100644 --- a/uv.lock +++ b/uv.lock @@ -365,6 +365,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -452,6 +464,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mcp" version = "1.27.2" @@ -555,6 +630,13 @@ dependencies = [ { name = "mcp" }, ] +[package.optional-dependencies] +web = [ + { name = "jinja2" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + [package.dev-dependencies] dev = [ { name = "coverage" }, @@ -565,7 +647,13 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "mcp", specifier = ">=1.2.0" }] +requires-dist = [ + { name = "jinja2", marker = "extra == 'web'", specifier = ">=3.1" }, + { name = "mcp", specifier = ">=1.2.0" }, + { name = "starlette", marker = "extra == 'web'", specifier = ">=0.37" }, + { name = "uvicorn", marker = "extra == 'web'", specifier = ">=0.30" }, +] +provides-extras = ["web"] [package.metadata.requires-dev] dev = [ From 7f80c70c2be86e58e190bbe47395fd8dcec64949 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:37:09 +1000 Subject: [PATCH 02/29] chore(web): add Task-3 removal hint to type: ignore on create_app import --- src/plainweave/web/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plainweave/web/server.py b/src/plainweave/web/server.py index 0cc3a01..211377f 100644 --- a/src/plainweave/web/server.py +++ b/src/plainweave/web/server.py @@ -40,9 +40,9 @@ def _serve( # pragma: no cover *, host: str, port: int, actor: str | None, open_browser: bool, root: Path | None = None ) -> int: # Lazy import: only touches starlette/uvicorn when the extra is installed. - import uvicorn # noqa: PLC0415 + import uvicorn # noqa: PLC0415, I001 - from plainweave.web.app import create_app # type: ignore[import-untyped] # noqa: PLC0415 + from plainweave.web.app import create_app # type: ignore[import-untyped] # noqa: PLC0415, I001 # remove when web/app.py lands (Task 3) app = create_app(actor=actor, root=root) if open_browser: From eaf54108dc9f1fa35d46ba52210bfe5e24e3954c Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:45:24 +1000 Subject: [PATCH 03/29] feat(web): request context with operator-actor resolution and CSRF helpers Co-Authored-By: Claude Sonnet 4.6 --- src/plainweave/web/context.py | 61 +++++++++++++++++++++++++++++++++++ tests/web/conftest.py | 15 +++++++++ tests/web/test_context.py | 47 +++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 src/plainweave/web/context.py create mode 100644 tests/web/conftest.py create mode 100644 tests/web/test_context.py diff --git a/src/plainweave/web/context.py b/src/plainweave/web/context.py new file mode 100644 index 0000000..93d6074 --- /dev/null +++ b/src/plainweave/web/context.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import secrets +from dataclasses import dataclass +from pathlib import Path + +from plainweave.errors import ErrorCode, PlainweaveError +from plainweave.paths import plainweave_db_path +from plainweave.service import PlainweaveService + +DEFAULT_OPERATOR_ID = "human:operator" + + +@dataclass(frozen=True) +class OperatorIdentity: + actor_id: str + display_name: str + kind: str + + +class RequestContext: + def __init__(self, service: PlainweaveService, operator: OperatorIdentity) -> None: + self.service = service + self.operator = operator + + @classmethod + def from_root(cls, root: Path | None, *, actor: str | None) -> RequestContext: + service = PlainweaveService(plainweave_db_path(root), root=root) + actor_id = actor or DEFAULT_OPERATOR_ID + display = actor_id.split(":", 1)[-1] or actor_id + operator = cls._ensure_operator(service, actor_id, display) + return cls(service, operator) + + @staticmethod + def _ensure_operator(service: PlainweaveService, actor_id: str, display: str) -> OperatorIdentity: + # Register the operator as a human actor. At genesis (no attester yet) this + # self-registration is permitted; once an attester exists, only an existing + # attester may (re)register a human — surface that clearly rather than crashing. + try: + service.register_actor(actor_id, kind="human", display_name=display, actor=actor_id) + except PlainweaveError as exc: + if exc.code is ErrorCode.POLICY_REQUIRED: + raise PlainweaveError( + ErrorCode.POLICY_REQUIRED, + f"operator actor {actor_id!r} is not a registered human and cannot self-register " + "(an attester already exists). Register it via the CLI before launching the web UI.", + recoverable=False, + hint="plainweave actor register --id --kind human --actor ", + ) from exc + raise + return OperatorIdentity(actor_id=actor_id, display_name=display, kind="human") + + +def new_csrf_token() -> str: + return secrets.token_urlsafe(32) + + +def csrf_ok(cookie_token: str | None, form_token: str | None) -> bool: + if not cookie_token or not form_token: + return False + return secrets.compare_digest(cookie_token, form_token) diff --git a/tests/web/conftest.py b/tests/web/conftest.py new file mode 100644 index 0000000..0c3c74a --- /dev/null +++ b/tests/web/conftest.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from plainweave.paths import default_project_key, plainweave_db_path +from plainweave.store import migrate + + +@pytest.fixture +def project_root(tmp_path: Path) -> Path: + # Initialize a fresh local store under a temp root. + migrate(plainweave_db_path(tmp_path), project_key=default_project_key(tmp_path)) + return tmp_path diff --git a/tests/web/test_context.py b/tests/web/test_context.py new file mode 100644 index 0000000..0d72306 --- /dev/null +++ b/tests/web/test_context.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from plainweave.errors import ErrorCode, PlainweaveError +from plainweave.store import connect +from plainweave.web import context as ctx + + +def test_operator_self_registers_at_genesis(project_root: Path) -> None: + rc = ctx.RequestContext.from_root(project_root, actor="human:alice") + assert rc.operator.actor_id == "human:alice" + assert rc.operator.kind == "human" + # The actor is now a registered human in the store. + with connect(rc.service.db_path) as conn: + row = conn.execute("select kind from actors where actor_id = ?", ("human:alice",)).fetchone() + assert row is not None + assert str(row["kind"]) == "human" + + +def test_default_operator_used_when_actor_omitted(project_root: Path) -> None: + rc = ctx.RequestContext.from_root(project_root, actor=None) + assert rc.operator.actor_id == ctx.DEFAULT_OPERATOR_ID + + +def test_from_root_is_idempotent(project_root: Path) -> None: + # Calling from_root twice with the same actor should succeed (re-register is ok). + rc1 = ctx.RequestContext.from_root(project_root, actor="human:alice") + rc2 = ctx.RequestContext.from_root(project_root, actor="human:alice") + assert rc1.operator.actor_id == rc2.operator.actor_id + + +def test_second_actor_cannot_self_register_after_genesis(project_root: Path) -> None: + # Once a genesis attester exists, a different unregistered actor cannot self-register. + ctx.RequestContext.from_root(project_root, actor="human:alice") # genesis + with pytest.raises(PlainweaveError) as exc: + ctx.RequestContext.from_root(project_root, actor="human:bob") + assert exc.value.code == ErrorCode.POLICY_REQUIRED + + +def test_csrf_roundtrip() -> None: + token = ctx.new_csrf_token() + assert ctx.csrf_ok(token, token) is True + assert ctx.csrf_ok(token, "other") is False + assert ctx.csrf_ok(None, token) is False From 519b7a35707270f28747203fb5af63b290e28ab1 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:57:20 +1000 Subject: [PATCH 04/29] test(conformance): producer freeze for the legis preflight-facts envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Producer-side conformance for plainweave's weft.plainweave.preflight_facts.v1 envelope (ADR-006). Re-captures the golden from the REAL producer PlainweaveMcpSurface.plainweave_preflight_facts_get over a fixed seeded project (replacing the stale hand-authored fixture — only producer.version 0.0.1->1.0.0 differed) + a Layer-1 byte-pin + a NON-CIRCULAR producer-source recheck that re-invokes the live producer and asserts == golden. The 2 non-deterministic fields (generated_at, producer.version) are validated against the live producer (aware ISO-8601 UTC; == plainweave.__version__) BEFORE normalizing, so drift reds non-circularly. Consumer side is legis (ringfenced/absent) -> producer-only. 2 tests pass; byte-pin + recheck both red on tamper. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_preflight_facts_wire_golden.py | 227 ++++++++++++++++++ .../contracts/legis/preflight-facts.json | 2 +- 2 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 tests/contracts/test_preflight_facts_wire_golden.py diff --git a/tests/contracts/test_preflight_facts_wire_golden.py b/tests/contracts/test_preflight_facts_wire_golden.py new file mode 100644 index 0000000..711208a --- /dev/null +++ b/tests/contracts/test_preflight_facts_wire_golden.py @@ -0,0 +1,227 @@ +"""Plainweave-authored ``weft.plainweave.preflight_facts.v1`` envelope frozen to a +vendored byte golden (ADR-006), with a non-circular producer-source recheck. + +``tests/fixtures/contracts/legis/preflight-facts.json`` is the preflight-facts +``schema + data`` payload plainweave emits from +``PlainweaveMcpSurface.plainweave_preflight_facts_get`` — the producer named in +ADR-006 (Status: Accepted). Legis is the intended CONSUMER of this envelope, but +the consumer side does NOT exist yet and legis is ringfenced, so this row is +PRODUCER-SIDE ONLY: it freezes plainweave's own produced bytes and ties them to +the live producer. There is no consumer oracle and no cross-repo drift check. + +PLAINWEAVE IS THE AUTHORITY for this seam — it OWNS the preflight-facts shape via +``PlainweaveMcpSurface.plainweave_preflight_facts_get``. The protection is a +two-layer affair (mirroring wardline's vocabulary-descriptor wire golden): + +* Layer-1 (``test_golden_matches_blob_pin``): a git-blob byte-pin on the vendored + golden, so any silent edit to the envelope wire reds the default suite. On its + OWN this is CIRCULAR — plainweave pins plainweave's own bytes. +* Producer-source recheck (``test_golden_matches_live_producer``): the + non-circular break. It re-invokes the REAL producer + (``PlainweaveMcpSurface.plainweave_preflight_facts_get``) over a fixed, + deterministically seeded tmp project and asserts the regenerated ``schema + + data`` payload EQUALS the frozen golden. The frozen bytes are tied to the live + producer, so if the envelope shape drifts from the golden — a fact kind + added/removed, a message/severity/provenance changed, a section added — it reds + even though the byte-pin still passes. + +NON-DETERMINISTIC / RELEASE-COUPLED FIELDS (honest caveat). The producer embeds +exactly two fields that are not byte-stable across runs/releases: + +* ``data.generated_at`` — ``datetime.now(UTC).isoformat()``; changes every call. +* ``data.producer.version`` — ``plainweave.__version__``; bumps every release with + no contract change. + +The golden freezes these to realistic, representative values +(``generated_at`` = ``2026-06-04T10:00:00+00:00``, ``producer.version`` = +``1.0.0``) so it reads as a real envelope and the byte-pin is stable across the +clock and releases. The recheck keeps them bound to the LIVE producer +NON-CIRCULARLY: BEFORE normalizing it asserts the regenerated ``generated_at`` +parses as an aware ISO-8601 UTC instant and that the regenerated +``producer.version`` EQUALS the live ``plainweave.__version__`` — these +pre-normalization asserts ARE the non-circularity for the two normalized fields. +ONLY THEN does it copy the golden's frozen values over those two fields and +assert deep dict-equality. A producer that dropped ``generated_at`` or emitted a +garbage version would red on the asserts, not be hidden by the normalization. + +RE-VENDOR PROCEDURE: if you deliberately change the preflight-facts shape (a new +fact kind, a changed message, an added section), regenerate the golden from the +real producer over the seeding below, freeze ``generated_at`` / +``producer.version`` back to the representative values, recompute the blob SHA +(``git hash-object tests/fixtures/contracts/legis/preflight-facts.json``) and +update ``UPSTREAM_BLOB_SHA`` in the SAME commit — the recheck will otherwise red. +The independent structural check in +``tests/contracts/test_contract_fixtures.py::test_preflight_facts_fixture_contract`` +validates the same golden through ``validate_preflight_facts`` as a free +cross-check. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +from datetime import datetime +from pathlib import Path +from typing import Any, cast + +from tests.loomweave_test_utils import seed_loomweave_catalog + +from plainweave import __version__ +from plainweave.mcp_surface import PlainweaveMcpSurface +from plainweave.models import TraceRef +from plainweave.service import PlainweaveService +from plainweave.store import migrate + +GOLDEN_PATH = Path(__file__).parents[1] / "fixtures" / "contracts" / "legis" / "preflight-facts.json" + +# Layer-1 byte-pin: the git-blob SHA-1 of legis/preflight-facts.json. Recomputed +# below as hashlib.sha1(b"blob %d\0" % len(data) + data) (== `git hash-object`). +# Any edit to the vendored golden without a matching re-pin reds the default suite. +UPSTREAM_BLOB_SHA = "10506f0359317da614237df3694f038bc141009e" + +# The two fields the producer cannot emit deterministically; the golden freezes +# them to representative values and the recheck re-binds them to the live producer. +_FROZEN_GENERATED_AT = "2026-06-04T10:00:00+00:00" +_FROZEN_VERSION = "1.0.0" + + +def _seed_preflight_project(root: Path) -> dict[str, Any]: + """Deterministically seed a tmp project that exercises every preflight fact kind. + + Mirrors the seeding in + ``tests/test_mcp_read_surface.py::test_mcp_preflight_facts_returns_scoped_advisory_facts_without_verdicts`` + so the regenerated envelope reproduces the frozen golden byte-for-byte (modulo + the two normalized fields). All inputs (IDs, SEIs, content hashes, dates) are + fixed, so the producer's output is stable across runs. + """ + db_path = root / ".plainweave" / "plainweave.db" + migrate(db_path, project_key="AUTH") + service = PlainweaveService(db_path, root=root) + seed = seed_loomweave_catalog(root) + + def approve(*, title: str, statement: str, criterion: str, key: str) -> str: + draft = service.create_requirement(title, statement, "human:john") + service.add_acceptance_criterion(draft.id, criterion, actor="human:john") + service.approve_requirement(draft.id, actor="human:john", expected_version=0, idempotency_key=key) + return draft.id + + stale = approve( + title="Rotate signing keys", + statement="The API shall rotate signing keys.", + criterion="Rotated keys are accepted.", + key="approve-stale", + ) + method = service.add_verification_method( + stale, method="test", target="tests/test_keys.py::test_rotation", actor="human:john" + ) + service.record_verification_evidence( + method.id, + status="passing", + evidence_ref="pytest:tests/test_keys.py::test_rotation", + actor="agent:codex", + ) + service.create_trace_link( + TraceRef("loomweave_entity", seed["public_locator"]), + "satisfies", + TraceRef("requirement_version", f"{stale}@1"), + actor="human:john", + authority="accepted", + ) + baseline = service.create_baseline("Release 1.0", actor="human:john") + service.supersede_requirement( + stale, + title="Rotate signing keys promptly", + statement="The API shall rotate signing keys within the configured window.", + actor="human:john", + expected_version=1, + idempotency_key="supersede-stale", + ) + missing = approve( + title="Audit password resets", + statement="The API shall audit password resets.", + criterion="Password resets are audited.", + key="approve-missing", + ) + return { + "root": root, + "stale": stale, + "missing": missing, + "baseline_id": baseline.id, + "public_sei": seed["public_sei"], + } + + +def _produce_schema_plus_data(root: Path) -> dict[str, Any]: + """Re-invoke the REAL producer and return the ``schema + data`` payload shape + the golden vendors (``{"schema": ..., **data}``, NOT the full envelope).""" + seeded = _seed_preflight_project(root) + surface = PlainweaveMcpSurface(root) + envelope = surface.plainweave_preflight_facts_get( + scope_kind="pending_diff", + base="main", + head="WORKTREE", + requirement_ids=[seeded["stale"], seeded["missing"]], + entity_refs=[seeded["public_sei"], "loomweave:eid:untraced"], + baseline_id=seeded["baseline_id"], + ) + assert envelope["ok"] is True + return {"schema": envelope["schema"], **cast(dict[str, Any], envelope["data"])} + + +def test_golden_matches_blob_pin() -> None: + """Layer-1 (default suite): the plainweave-authored preflight-facts golden + byte-pins to its git blob hash. ANY edit without a matching re-pin reds the + default suite. On its OWN this pin is plainweave-pins-plainweave (circular); + the non-circular protection is ``test_golden_matches_live_producer`` below, + which regenerates the payload from the LIVE producer.""" + assert len(UPSTREAM_BLOB_SHA) == 40 and set(UPSTREAM_BLOB_SHA) <= set("0123456789abcdef"), ( + f"UPSTREAM_BLOB_SHA must be 40 lowercase hex chars (a git blob SHA-1): {UPSTREAM_BLOB_SHA!r}" + ) + data = GOLDEN_PATH.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == UPSTREAM_BLOB_SHA, ( + f"the vendored preflight-facts golden changed (git blob {actual}, pinned {UPSTREAM_BLOB_SHA}) — " + "if this was a deliberate re-vendor, regenerate it from the real producer, freeze generated_at / " + "producer.version back to the representative values, update UPSTREAM_BLOB_SHA in the same commit " + "(see the RE-VENDOR PROCEDURE at the top of this module); if not, revert the edit." + ) + + +def test_golden_matches_live_producer(tmp_path: Path) -> None: + """PRODUCER-SOURCE recheck (non-circular): regenerate the preflight-facts + payload from the REAL ``plainweave_preflight_facts_get`` producer over a fixed + seeded project and assert it EQUALS the frozen golden. This ties the + byte-pinned golden to the live producer, so an envelope-shape drift — a fact + kind added/removed, a message/severity/provenance changed, a section added — + without a re-vendor reds even though the byte-pin still passes. + + The two non-deterministic / release-coupled fields (``generated_at``, + ``producer.version``) are asserted LIVE *before* being normalized to the + golden's frozen values; those pre-normalization asserts are what keep drift + coverage on the exact fields the normalization clobbers.""" + golden = cast(dict[str, Any], json.loads(GOLDEN_PATH.read_text("utf-8"))) + regenerated = _produce_schema_plus_data(tmp_path) + + # --- non-circular core: assert the LIVE values BEFORE clobbering them --- + generated_at = regenerated["generated_at"] + parsed = datetime.fromisoformat(generated_at) # valid ISO-8601… + assert parsed.utcoffset() is not None and parsed.utcoffset().total_seconds() == 0, ( # type: ignore[union-attr] + f"generated_at must be an aware UTC instant, got {generated_at!r}" + ) + assert regenerated["producer"]["version"] == __version__, ( + f"producer.version must equal the live plainweave.__version__ ({__version__!r}), " + f"got {regenerated['producer']['version']!r}" + ) + + # --- normalize the two fields to the golden's frozen values, then deep-compare --- + assert golden["generated_at"] == _FROZEN_GENERATED_AT + assert golden["producer"]["version"] == _FROZEN_VERSION + normalized = copy.deepcopy(regenerated) + normalized["generated_at"] = golden["generated_at"] + normalized["producer"]["version"] = golden["producer"]["version"] + + assert normalized == golden, ( + "the live preflight-facts producer drifted from the vendored golden — see the " + "RE-VENDOR PROCEDURE at the top of this module." + ) diff --git a/tests/fixtures/contracts/legis/preflight-facts.json b/tests/fixtures/contracts/legis/preflight-facts.json index e187b46..10506f0 100644 --- a/tests/fixtures/contracts/legis/preflight-facts.json +++ b/tests/fixtures/contracts/legis/preflight-facts.json @@ -2,7 +2,7 @@ "schema": "weft.plainweave.preflight_facts.v1", "producer": { "tool": "plainweave", - "version": "0.0.1", + "version": "1.0.0", "project": "AUTH" }, "scope": { From f7ea83cf7095df7ffeb5a766b8efcac2152dcd06 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:57:39 +1000 Subject: [PATCH 05/29] feat(web): app factory, PlainweaveError->HTTP mapping, base layout, CSRF middleware Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 1 + src/plainweave/web/app.py | 71 ++++++++++++++++++ src/plainweave/web/errors.py | 20 +++++ src/plainweave/web/routes/__init__.py | 13 ++++ src/plainweave/web/routes/goals.py | 6 ++ src/plainweave/web/routes/intent.py | 6 ++ src/plainweave/web/routes/requirements.py | 6 ++ src/plainweave/web/routes/review.py | 6 ++ src/plainweave/web/server.py | 2 +- src/plainweave/web/static/app.css | 19 +++++ src/plainweave/web/static/htmx.min.js | 1 + .../web/templates/_partials/error.html | 7 ++ src/plainweave/web/templates/base.html | 33 +++++++++ tests/web/test_app.py | 74 +++++++++++++++++++ tests/web/test_errors.py | 12 +++ uv.lock | 2 + 16 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 src/plainweave/web/app.py create mode 100644 src/plainweave/web/errors.py create mode 100644 src/plainweave/web/routes/__init__.py create mode 100644 src/plainweave/web/routes/goals.py create mode 100644 src/plainweave/web/routes/intent.py create mode 100644 src/plainweave/web/routes/requirements.py create mode 100644 src/plainweave/web/routes/review.py create mode 100644 src/plainweave/web/static/app.css create mode 100644 src/plainweave/web/static/htmx.min.js create mode 100644 src/plainweave/web/templates/_partials/error.html create mode 100644 src/plainweave/web/templates/base.html create mode 100644 tests/web/test_app.py create mode 100644 tests/web/test_errors.py diff --git a/pyproject.toml b/pyproject.toml index 825c902..380fe45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ [dependency-groups] dev = [ "coverage[toml]>=7.0", + "jinja2>=3.1.6", "mypy>=1.13.0", "pytest>=8.0", "pytest-cov>=5.0", diff --git a/src/plainweave/web/app.py b/src/plainweave/web/app.py new file mode 100644 index 0000000..e4cb969 --- /dev/null +++ b/src/plainweave/web/app.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from pathlib import Path + +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import PlainTextResponse, Response +from starlette.routing import Mount, Route +from starlette.staticfiles import StaticFiles +from starlette.templating import Jinja2Templates + +from plainweave.errors import PlainweaveError +from plainweave.web.context import RequestContext, csrf_ok, new_csrf_token +from plainweave.web.errors import error_to_status + +_HERE = Path(__file__).parent +_CSRF_COOKIE = "pw_csrf" + + +def create_app(*, actor: str | None, root: Path | None) -> Starlette: + templates = Jinja2Templates(directory=str(_HERE / "templates")) + + def ctx_factory() -> RequestContext: + return RequestContext.from_root(root, actor=actor) + + async def healthz(request: Request) -> Response: + return PlainTextResponse("ok") + + async def on_error(request: Request, exc: Exception) -> Response: + if isinstance(exc, PlainweaveError): + status = error_to_status(exc.code) + return templates.TemplateResponse( + request, + "_partials/error.html", + {"code": exc.code.value, "message": exc.message, "hint": exc.hint}, + status_code=status, + ) + raise exc + + async def csrf_mw(request: Request, call_next: RequestResponseEndpoint) -> Response: + token = request.cookies.get(_CSRF_COOKIE) + if request.method in {"POST", "PUT", "PATCH", "DELETE"}: + form = await request.form() + form_token = str(form.get("_csrf")) if form.get("_csrf") is not None else None + if not csrf_ok(token, form_token): + return PlainTextResponse("CSRF check failed", status_code=403) + response = await call_next(request) + if token is None: + token = new_csrf_token() + response.set_cookie(_CSRF_COOKIE, token, httponly=True, samesite="strict") + return response + + routes = [ + Route("/healthz", healthz), + Mount("/static", app=StaticFiles(directory=str(_HERE / "static")), name="static"), + ] + app = Starlette( + routes=routes, + middleware=[Middleware(BaseHTTPMiddleware, dispatch=csrf_mw)], + exception_handlers={PlainweaveError: on_error}, + ) + app.state.templates = templates + app.state.ctx_factory = ctx_factory + app.state.csrf_cookie = _CSRF_COOKIE + + from plainweave.web.routes import register_all + + register_all(app) + return app diff --git a/src/plainweave/web/errors.py b/src/plainweave/web/errors.py new file mode 100644 index 0000000..f8d7f8e --- /dev/null +++ b/src/plainweave/web/errors.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from plainweave.errors import ErrorCode + +_STATUS: dict[ErrorCode, int] = { + ErrorCode.VALIDATION: 400, + ErrorCode.NOT_FOUND: 404, + ErrorCode.CONFLICT: 409, + ErrorCode.POLICY_REQUIRED: 409, + ErrorCode.LOCKED: 409, + ErrorCode.PEER_ABSENT: 503, + ErrorCode.PEER_STALE: 503, + ErrorCode.PEER_CONTRACT: 502, + ErrorCode.UNSUPPORTED: 400, + ErrorCode.INTERNAL: 500, +} + + +def error_to_status(code: ErrorCode) -> int: + return _STATUS.get(code, 500) diff --git a/src/plainweave/web/routes/__init__.py b/src/plainweave/web/routes/__init__.py new file mode 100644 index 0000000..9075af3 --- /dev/null +++ b/src/plainweave/web/routes/__init__.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from starlette.applications import Starlette + + +def register_all(app: Starlette) -> None: + # Each route module appends its routes; populated as tasks land. + from plainweave.web.routes import goals, intent, requirements, review + + requirements.register(app) + intent.register(app) + review.register(app) + goals.register(app) diff --git a/src/plainweave/web/routes/goals.py b/src/plainweave/web/routes/goals.py new file mode 100644 index 0000000..03ac990 --- /dev/null +++ b/src/plainweave/web/routes/goals.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from starlette.applications import Starlette + + +def register(app: Starlette) -> None: ... diff --git a/src/plainweave/web/routes/intent.py b/src/plainweave/web/routes/intent.py new file mode 100644 index 0000000..03ac990 --- /dev/null +++ b/src/plainweave/web/routes/intent.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from starlette.applications import Starlette + + +def register(app: Starlette) -> None: ... diff --git a/src/plainweave/web/routes/requirements.py b/src/plainweave/web/routes/requirements.py new file mode 100644 index 0000000..03ac990 --- /dev/null +++ b/src/plainweave/web/routes/requirements.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from starlette.applications import Starlette + + +def register(app: Starlette) -> None: ... diff --git a/src/plainweave/web/routes/review.py b/src/plainweave/web/routes/review.py new file mode 100644 index 0000000..03ac990 --- /dev/null +++ b/src/plainweave/web/routes/review.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from starlette.applications import Starlette + + +def register(app: Starlette) -> None: ... diff --git a/src/plainweave/web/server.py b/src/plainweave/web/server.py index 211377f..7d2de6b 100644 --- a/src/plainweave/web/server.py +++ b/src/plainweave/web/server.py @@ -42,7 +42,7 @@ def _serve( # pragma: no cover # Lazy import: only touches starlette/uvicorn when the extra is installed. import uvicorn # noqa: PLC0415, I001 - from plainweave.web.app import create_app # type: ignore[import-untyped] # noqa: PLC0415, I001 # remove when web/app.py lands (Task 3) + from plainweave.web.app import create_app # noqa: PLC0415, I001 app = create_app(actor=actor, root=root) if open_browser: diff --git a/src/plainweave/web/static/app.css b/src/plainweave/web/static/app.css new file mode 100644 index 0000000..da03f0a --- /dev/null +++ b/src/plainweave/web/static/app.css @@ -0,0 +1,19 @@ +:root { --amber: #c47b1a; --warn-bg: #fdf3e3; --line: #d9d9d9; } +body { font-family: system-ui, sans-serif; margin: 0; color: #1c1c1c; font-size: 16px; } +.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.skip-link { position: absolute; left: -999px; } +.skip-link:focus { left: 1rem; top: 0.5rem; background: #fff; padding: 0.5rem; } +.topnav { display: flex; gap: 1rem; align-items: center; padding: 0.6rem 1rem; border-bottom: 1px solid var(--line); } +.topnav a[aria-current="page"] { font-weight: 700; text-decoration: underline; } +.nav-badge:not(:empty) { background: #b00; color: #fff; border-radius: 8px; padding: 0 6px; font-size: 0.75rem; } +.operator { margin-left: auto; opacity: 0.7; font-size: 0.85rem; } +main { padding: 1rem; } +table { border-collapse: collapse; width: 100%; font-size: 14px; } +th, td { text-align: left; padding: 6px 8px; border-top: 1px solid var(--line); } +.htmx-indicator { opacity: 0; transition: opacity 0.1s; } +.htmx-request .htmx-indicator, .htmx-indicator.htmx-request { opacity: 1; } +.queue-item--drifted { border: 1px solid var(--amber); border-left-width: 4px; background: var(--warn-bg); padding: 0.6rem; } +.drift-badge { display: inline-block; background: var(--amber); color: #fff; font-size: 0.7rem; font-weight: 700; padding: 1px 7px; border-radius: 4px; } +.toggle-btn { border: 1px solid var(--line); border-radius: 4px; padding: 3px 9px; font-size: 0.8rem; } +.toggle-btn--active { border-width: 2px; font-weight: 700; } +.toggle-btn--active::before { content: "✓ "; } diff --git a/src/plainweave/web/static/htmx.min.js b/src/plainweave/web/static/htmx.min.js new file mode 100644 index 0000000..de5f0f1 --- /dev/null +++ b/src/plainweave/web/static/htmx.min.js @@ -0,0 +1 @@ +(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.12"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t){return new RegExp("<"+e+"(\\s[^>]*>|>)([\\s\\S]*?)<\\/"+e+">",!!t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function s(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/",0);var a=i.querySelector("template").content;if(Q.config.allowScriptTags){oe(a.querySelectorAll("script"),function(e){if(Q.config.inlineScriptNonce){e.nonce=Q.config.inlineScriptNonce}e.htmxExecuted=navigator.userAgent.indexOf("Firefox")===-1})}else{oe(a.querySelectorAll("script"),function(e){_(e)})}return a}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s(""+n+"
",1);case"col":return s(""+n+"
",2);case"tr":return s(""+n+"
",2);case"td":case"th":return s(""+n+"
",3);case"script":case"style":return s("
"+n+"
",1);default:return s(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r=0}function se(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return re().body.contains(e.getRootNode().host)}else{return re().body.contains(e)}}function D(e){return e.trim().split(/\s+/)}function le(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function E(e){try{return JSON.parse(e)}catch(e){b(e);return null}}function U(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function B(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function t(e){return Tr(re().body,function(){return eval(e)})}function F(t){var e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function j(){Q.logger=null}function C(e,t){if(t){return e.querySelector(t)}else{return C(re(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(re(),e)}}function _(e,t){e=p(e);if(t){setTimeout(function(){_(e);e=null},t)}else{e.parentElement.removeChild(e)}}function z(e,t,r){e=p(e);if(r){setTimeout(function(){z(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=p(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function $(e,t){e=p(e);e.classList.toggle(t)}function W(e,t){e=p(e);oe(e.parentElement.children,function(e){n(e,t)});z(e,t)}function v(e,t){e=p(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function g(e,t){return e.substring(0,t.length)===t}function G(e,t){return e.substring(e.length-t.length)===t}function J(e){var t=e.trim();if(g(t,"<")&&G(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function Z(e,t){if(t.indexOf("closest ")===0){return[v(e,J(t.substr(8)))]}else if(t.indexOf("find ")===0){return[C(e,J(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[K(e,J(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[Y(e,J(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return re().querySelectorAll(J(t))}}var K=function(e,t){var r=re().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ue(e,t){if(t){return Z(e,t)[0]}else{return Z(re().body,e)[0]}}function p(e){if(I(e,"String")){return C(e)}else{return e}}function ve(e,t,r){if(k(t)){return{target:re().body,event:e,listener:t}}else{return{target:p(e),event:t,listener:r}}}function de(t,r,n){jr(function(){var e=ve(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=k(r);return e?r:n}function ge(t,r,n){jr(function(){var e=ve(t,r,n);e.target.removeEventListener(e.event,e.listener)});return k(r)?r:n}var pe=re().createElement("output");function me(e,t){var r=ne(e,t);if(r){if(r==="this"){return[xe(e,t)]}else{var n=Z(e,r);if(n.length===0){b('The selector "'+r+'" on '+t+" returned no matches!");return[pe]}else{return n}}}}function xe(e,t){return c(e,function(e){return te(e,t)!=null})}function ye(e){var t=ne(e,"hx-target");if(t){if(t==="this"){return xe(e,"hx-target")}else{return ue(e,t)}}else{var r=ae(e);if(r.boosted){return re().body}else{return e}}}function be(e){var t=Q.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=re().querySelectorAll(t);if(r){oe(r,function(e){var t;var r=i.cloneNode(true);t=re().createDocumentFragment();t.appendChild(r);if(!Se(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!ce(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Fe(o,e,e,t,a)}oe(a.elts,function(e){ce(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);fe(re().body,"htmx:oobErrorNoTarget",{content:i})}return e}function Ce(e,t,r){var n=ne(e,"hx-select-oob");if(n){var i=n.split(",");for(var a=0;a0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();we(e,i);s.tasks.push(function(){we(e,a)})}}})}function Oe(e){return function(){n(e,Q.config.addedClass);zt(e);Nt(e);qe(e);ce(e,"htmx:load")}}function qe(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){Te(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;z(i,Q.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Oe(i))}}}function He(e,t){var r=0;while(r-1){var t=e.replace(H,"");var r=t.match(q);if(r){return r[2]}}}function je(e,t,r,n,i,a){i.title=Ve(n);var o=l(n);if(o){Ce(r,o,i);o=Be(r,o,a);Re(o);return Fe(e,r,t,o,i)}}function _e(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=E(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!P(o)){o={value:o}}ce(r,a,o)}}}else{var s=n.split(",");for(var l=0;l0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=Tr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){fe(re().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(Qe(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function y(e,t){var r="";while(e.length>0&&!t.test(e[0])){r+=e.shift()}return r}function tt(e){var t;if(e.length>0&&Ze.test(e[0])){e.shift();t=y(e,Ke).trim();e.shift()}else{t=y(e,x)}return t}var rt="input, textarea, select";function nt(e,t,r){var n=[];var i=Ye(t);do{y(i,Je);var a=i.length;var o=y(i,/[,\[\s]/);if(o!==""){if(o==="every"){var s={trigger:"every"};y(i,Je);s.pollInterval=d(y(i,/[,\[\s]/));y(i,Je);var l=et(e,i,"event");if(l){s.eventFilter=l}n.push(s)}else if(o.indexOf("sse:")===0){n.push({trigger:"sse",sseEvent:o.substr(4)})}else{var u={trigger:o};var l=et(e,i,"event");if(l){u.eventFilter=l}while(i.length>0&&i[0]!==","){y(i,Je);var f=i.shift();if(f==="changed"){u.changed=true}else if(f==="once"){u.once=true}else if(f==="consume"){u.consume=true}else if(f==="delay"&&i[0]===":"){i.shift();u.delay=d(y(i,x))}else if(f==="from"&&i[0]===":"){i.shift();if(Ze.test(i[0])){var c=tt(i)}else{var c=y(i,x);if(c==="closest"||c==="find"||c==="next"||c==="previous"){i.shift();var h=tt(i);if(h.length>0){c+=" "+h}}}u.from=c}else if(f==="target"&&i[0]===":"){i.shift();u.target=tt(i)}else if(f==="throttle"&&i[0]===":"){i.shift();u.throttle=d(y(i,x))}else if(f==="queue"&&i[0]===":"){i.shift();u.queue=y(i,x)}else if(f==="root"&&i[0]===":"){i.shift();u[f]=tt(i)}else if(f==="threshold"&&i[0]===":"){i.shift();u[f]=y(i,x)}else{fe(e,"htmx:syntax:error",{token:i.shift()})}}n.push(u)}}if(i.length===a){fe(e,"htmx:syntax:error",{token:i.shift()})}y(i,Je)}while(i[0]===","&&i.shift());if(r){r[t]=n}return n}function it(e){var t=te(e,"hx-trigger");var r=[];if(t){var n=Q.config.triggerSpecsCache;r=n&&n[t]||nt(e,t,n)}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,rt)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function at(e){ae(e).cancelled=true}function ot(e,t,r){var n=ae(e);n.timeout=setTimeout(function(){if(se(e)&&n.cancelled!==true){if(!ct(r,e,Wt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}ot(e,t,r)}},r.pollInterval)}function st(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function lt(t,r,e){if(t.tagName==="A"&&st(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=ee(t,"href")}else{var a=ee(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=ee(t,"action")}e.forEach(function(e){ht(t,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(n,i,e,t)},r,e,true)})}}function ut(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&v(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ft(e,t){return ae(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function ct(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){fe(re().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function ht(a,o,e,s,l){var u=ae(a);var t;if(s.from){t=Z(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ae(e);t.lastValue=e.value})}oe(t,function(n){var i=function(e){if(!se(a)){n.removeEventListener(s.trigger,i);return}if(ft(a,e)){return}if(l||ut(e,a)){e.preventDefault()}if(ct(s,a,e)){return}var t=ae(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ae(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle>0){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay>0){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{ce(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var vt=false;var dt=null;function gt(){if(!dt){dt=function(){vt=true};window.addEventListener("scroll",dt);setInterval(function(){if(vt){vt=false;oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){pt(e)})}},200)}}function pt(t){if(!o(t,"data-hx-revealed")&&X(t)){t.setAttribute("data-hx-revealed","true");var e=ae(t);if(e.initHash){ce(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){ce(t,"revealed")},{once:true})}}}function mt(e,t,r){var n=D(r);for(var i=0;i=0){var t=wt(n);setTimeout(function(){xt(s,r,n+1)},t)}};t.onopen=function(e){n=0};ae(s).webSocket=t;t.addEventListener("message",function(e){if(yt(s)){return}var t=e.data;R(s,function(e){t=e.transformResponse(t,null,s)});var r=T(s);var n=l(t);var i=M(n.children);for(var a=0;a0){ce(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(ut(e,u)){e.preventDefault()}})}else{fe(u,"htmx:noWebSocketSourceError")}}function wt(e){var t=Q.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}b('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function St(e,t,r){var n=D(r);for(var i=0;i0){setTimeout(i,n)}else{i()}}function Ht(t,i,e){var a=false;oe(w,function(r){if(o(t,"hx-"+r)){var n=te(t,"hx-"+r);a=true;i.path=n;i.verb=r;e.forEach(function(e){Lt(t,e,i,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(r,n,e,t)})})}});return a}function Lt(n,e,t,r){if(e.sseEvent){Rt(n,r,e.sseEvent)}else if(e.trigger==="revealed"){gt();ht(n,r,t,e);pt(n)}else if(e.trigger==="intersect"){var i={};if(e.root){i.root=ue(n,e.root)}if(e.threshold){i.threshold=parseFloat(e.threshold)}var a=new IntersectionObserver(function(e){for(var t=0;t0){t.polling=true;ot(n,r,e)}else{ht(n,r,t,e)}}function At(e){if(!e.htmxExecuted&&Q.config.allowScriptTags&&(e.type==="text/javascript"||e.type==="module"||e.type==="")){var t=re().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}var r=e.parentElement;try{r.insertBefore(t,e)}catch(e){b(e)}finally{if(e.parentElement){e.parentElement.removeChild(e)}}}}function Nt(e){if(h(e,"script")){At(e)}oe(f(e,"script"),function(e){At(e)})}function It(e){var t=e.attributes;if(!t){return false}for(var r=0;r0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Bt(o)}for(var l in r){Ft(e,l,r[l])}}}function jt(e){Ae(e);for(var t=0;tQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(re().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Yt(e){if(!U()){return null}e=B(e);var t=E(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Zt();var r=T(t);var n=Ve(this.response);if(n){var i=C("title");if(i){i.innerHTML=n}else{window.document.title=n}}Ue(t,e,r);nr(r.tasks);Jt=a;ce(re().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{fe(re().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function ar(e){er();e=e||location.pathname+location.search;var t=Yt(e);if(t){var r=l(t.content);var n=Zt();var i=T(n);Ue(n,r,i);nr(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Jt=e;ce(re().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{ir(e)}}}function or(e){var t=me(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Q.config.requestClass)});return t}function sr(e){var t=me(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function lr(e,t){oe(e,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Q.config.requestClass)}});oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function ur(e,t){for(var r=0;r=0}function wr(e,t){var r=t?t:ne(e,"hx-swap");var n={swapStyle:ae(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(e).boosted&&!br(e)){n["show"]="top"}if(r){var i=D(r);if(i.length>0){for(var a=0;a0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var v=o.substr("focus-scroll:".length);n["focusScroll"]=v=="true"}else if(a==0){n["swapStyle"]=o}else{b("Unknown modifier in hx-swap: "+o)}}}}return n}function Sr(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function Er(t,r,n){var i=null;R(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(Sr(r)){return mr(n)}else{return pr(n)}}}function T(e){return{tasks:[],elts:[e]}}function Cr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ue(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ue(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Rr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=te(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=Tr(e,function(){return Function("return ("+a+")")()},{})}else{s=E(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return Rr(u(e),t,r,n)}function Tr(e,t,r){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return r}}function Or(e,t){return Rr(e,"hx-vars",true,t)}function qr(e,t){return Rr(e,"hx-vals",false,t)}function Hr(e){return le(Or(e),qr(e))}function Lr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function Ar(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(re().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Nr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||I(r,"String")){return he(e,t,null,null,{targetOverride:p(r),returnPromise:true})}else{return he(e,t,p(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:p(r.target),swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Ir(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function kr(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=g(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!n){return false}}return ce(e,"htmx:validateUrl",le({url:i,sameHost:n},r))}function he(t,r,n,i,a,e){var o=null;var s=null;a=a!=null?a:{};if(a.returnPromise&&typeof Promise!=="undefined"){var l=new Promise(function(e,t){o=e;s=t})}if(n==null){n=re().body}var M=a.handler||Mr;var X=a.select||null;if(!se(n)){ie(o);return l}var u=a.targetOverride||ye(n);if(u==null||u==pe){fe(n,"htmx:targetError",{target:te(n,"hx-target")});ie(s);return l}var f=ae(n);var c=f.lastButtonClicked;if(c){var h=ee(c,"formaction");if(h!=null){r=h}var v=ee(c,"formmethod");if(v!=null){if(v.toLowerCase()!=="dialog"){t=v}}}var d=ne(n,"hx-confirm");if(e===undefined){var D=function(e){return he(t,r,n,i,a,!!e)};var U={target:u,elt:n,path:r,verb:t,triggeringEvent:i,etc:a,issueRequest:D,question:d};if(ce(n,"htmx:confirm",U)===false){ie(o);return l}}var g=n;var p=ne(n,"hx-sync");var m=null;var x=false;if(p){var B=p.split(":");var F=B[0].trim();if(F==="this"){g=xe(n,"hx-sync")}else{g=ue(n,F)}p=(B[1]||"drop").trim();f=ae(g);if(p==="drop"&&f.xhr&&f.abortable!==true){ie(o);return l}else if(p==="abort"){if(f.xhr){ie(o);return l}else{x=true}}else if(p==="replace"){ce(g,"htmx:abort")}else if(p.indexOf("queue")===0){var V=p.split(" ");m=(V[1]||"last").trim()}}if(f.xhr){if(f.abortable){ce(g,"htmx:abort")}else{if(m==null){if(i){var y=ae(i);if(y&&y.triggerSpec&&y.triggerSpec.queue){m=y.triggerSpec.queue}}if(m==null){m="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(m==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="all"){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){he(t,r,n,i,a)})}ie(o);return l}}var b=new XMLHttpRequest;f.xhr=b;f.abortable=x;var w=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var j=ne(n,"hx-prompt");if(j){var S=prompt(j);if(S===null||!ce(n,"htmx:prompt",{prompt:S,target:u})){ie(o);w();return l}}if(d&&!e){if(!confirm(d)){ie(o);w();return l}}var E=xr(n,u,S);if(t!=="get"&&!Sr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(a.headers){E=le(E,a.headers)}var _=dr(n,t);var C=_.errors;var R=_.values;if(a.values){R=le(R,a.values)}var z=Hr(n);var $=le(R,z);var T=yr($,n);if(Q.config.getCacheBusterParam&&t==="get"){T["org.htmx.cache-buster"]=ee(u,"id")||"true"}if(r==null||r===""){r=re().location.href}var O=Rr(n,"hx-request");var W=ae(n).boosted;var q=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;var H={boosted:W,useUrlParams:q,parameters:T,unfilteredParameters:$,headers:E,target:u,verb:t,errors:C,withCredentials:a.credentials||O.credentials||Q.config.withCredentials,timeout:a.timeout||O.timeout||Q.config.timeout,path:r,triggeringEvent:i};if(!ce(n,"htmx:configRequest",H)){ie(o);w();return l}r=H.path;t=H.verb;E=H.headers;T=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){ce(n,"htmx:validation:halted",H);ie(o);w();return l}var G=r.split("#");var J=G[0];var L=G[1];var A=r;if(q){A=J;var Z=Object.keys(T).length!==0;if(Z){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=pr(T);if(L){A+="#"+L}}}if(!kr(n,A,H)){fe(n,"htmx:invalidPath",H);ie(s);return l}b.open(t.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(O.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var K=E[N];Lr(b,N,K)}}}var I={xhr:b,target:u,requestConfig:H,etc:a,boosted:W,select:X,pathInfo:{requestPath:r,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=Ir(n);I.pathInfo.responsePath=Ar(b);M(n,I);lr(k,P);ce(n,"htmx:afterRequest",I);ce(n,"htmx:afterOnLoad",I);if(!se(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(se(r)){t=r}}if(t){ce(t,"htmx:afterRequest",I);ce(t,"htmx:afterOnLoad",I)}}ie(o);w()}catch(e){fe(n,"htmx:onLoadError",le({error:e},I));throw e}};b.onerror=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendError",I);ie(s);w()};b.onabort=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendAbort",I);ie(s);w()};b.ontimeout=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:timeout",I);ie(s);w()};if(!ce(n,"htmx:beforeRequest",I)){ie(o);w();return l}var k=or(n);var P=sr(n);oe(["loadstart","loadend","progress","abort"],function(t){oe([b,b.upload],function(e){e.addEventListener(t,function(e){ce(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ce(n,"htmx:beforeSend",I);var Y=q?null:Er(b,n,T);b.send(Y);return l}function Pr(e,t){var r=t.xhr;var n=null;var i=null;if(O(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(O(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(O(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=ne(e,"hx-push-url");var l=ne(e,"hx-replace-url");var u=ae(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function Mr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;var h=u.select;if(!ce(l,"htmx:beforeOnLoad",u))return;if(O(f,/HX-Trigger:/i)){_e(f,"HX-Trigger",l)}if(O(f,/HX-Location:/i)){er();var r=f.getResponseHeader("HX-Location");var v;if(r.indexOf("{")===0){v=E(r);r=v["path"];delete v["path"]}Nr("GET",r,v).then(function(){tr(r)});return}var n=O(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(O(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(O(f,/HX-Retarget:/i)){if(f.getResponseHeader("HX-Retarget")==="this"){u.target=l}else{u.target=ue(l,f.getResponseHeader("HX-Retarget"))}}var d=Pr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var g=f.response;var a=f.status>=400;var p=Q.config.ignoreTitle;var o=le({shouldSwap:i,serverResponse:g,isError:a,ignoreTitle:p},u);if(!ce(c,"htmx:beforeSwap",o))return;c=o.target;g=o.serverResponse;a=o.isError;p=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){at(l)}R(l,function(e){g=e.transformResponse(g,f,l)});if(d.type){er()}var s=e.swapOverride;if(O(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var v=wr(l,s);if(v.hasOwnProperty("ignoreTitle")){p=v.ignoreTitle}c.classList.add(Q.config.swappingClass);var m=null;var x=null;var y=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(h){r=h}if(O(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}if(d.type){ce(re().body,"htmx:beforeHistoryUpdate",le({history:d},u));if(d.type==="push"){tr(d.path);ce(re().body,"htmx:pushedIntoHistory",{path:d.path})}else{rr(d.path);ce(re().body,"htmx:replacedInHistory",{path:d.path})}}var n=T(c);je(v.swapStyle,c,l,g,n,r);if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){var i=document.getElementById(ee(t.elt,"id"));var a={preventScroll:v.focusScroll!==undefined?!v.focusScroll:!Q.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Q.config.swappingClass);oe(n.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}ce(e,"htmx:afterSwap",u)});if(O(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!se(l)){o=re().body}_e(f,"HX-Trigger-After-Swap",o)}var s=function(){oe(n.tasks,function(e){e.call()});oe(n.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}ce(e,"htmx:afterSettle",u)});if(u.pathInfo.anchor){var e=re().getElementById(u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!p){var t=C("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}Cr(n.elts,v);if(O(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!se(l)){r=re().body}_e(f,"HX-Trigger-After-Settle",r)}ie(m)};if(v.settleDelay>0){setTimeout(s,v.settleDelay)}else{s()}}catch(e){fe(l,"htmx:swapError",u);ie(x);throw e}};var b=Q.config.globalViewTransitions;if(v.hasOwnProperty("transition")){b=v.transition}if(b&&ce(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var w=new Promise(function(e,t){m=e;x=t});var S=y;y=function(){document.startViewTransition(function(){S();return w})}}if(v.swapDelay>0){setTimeout(y,v.swapDelay)}else{y()}}if(a){fe(l,"htmx:responseError",le({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Xr={};function Dr(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Ur(e,t){if(t.init){t.init(r)}Xr[e]=le(Dr(),t)}function Br(e){delete Xr[e]}function Fr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=te(e,"hx-ext");if(t){oe(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Xr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Fr(u(e),r,n)}var Vr=false;re().addEventListener("DOMContentLoaded",function(){Vr=true});function jr(e){if(Vr||re().readyState==="complete"){e()}else{re().addEventListener("DOMContentLoaded",e)}}function _r(){if(Q.config.includeIndicatorStyles!==false){re().head.insertAdjacentHTML("beforeend","")}}function zr(){var e=re().querySelector('meta[name="htmx-config"]');if(e){return E(e.content)}else{return null}}function $r(){var e=zr();if(e){Q.config=le(Q.config,e)}}jr(function(){$r();_r();var e=re().body;zt(e);var t=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ae(t);if(r&&r.xhr){r.xhr.abort()}});const r=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){ar();oe(t,function(e){ce(e,"htmx:restored",{document:re(),triggerEvent:ce})})}else{if(r){r(e)}}};setTimeout(function(){ce(e,"htmx:load",{});e=null},0)});return Q}()}); \ No newline at end of file diff --git a/src/plainweave/web/templates/_partials/error.html b/src/plainweave/web/templates/_partials/error.html new file mode 100644 index 0000000..cb359ca --- /dev/null +++ b/src/plainweave/web/templates/_partials/error.html @@ -0,0 +1,7 @@ +
+

Something went wrong

+

{{ code }}

+

{{ message }}

+ {% if hint %}

{{ hint }}

{% endif %} +

Back to corpus

+
diff --git a/src/plainweave/web/templates/base.html b/src/plainweave/web/templates/base.html new file mode 100644 index 0000000..8277c71 --- /dev/null +++ b/src/plainweave/web/templates/base.html @@ -0,0 +1,33 @@ + + + + + + {% block title %}Plainweave{% endblock %} + + + + + + + + + {# Permanent SR status live region — NEVER replaced via outerHTML; innerHTML-OOB only. #} +
+ {# Decorative global loader; status comes from #sr-status, so this is aria-hidden. #} + + +
+ {% block main %}{% endblock %} +
+ + diff --git a/tests/web/test_app.py b/tests/web/test_app.py new file mode 100644 index 0000000..f27a50e --- /dev/null +++ b/tests/web/test_app.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import pytest +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Route +from starlette.testclient import TestClient + +from plainweave.errors import ErrorCode, PlainweaveError +from plainweave.web.app import create_app + + +@pytest.fixture +def client(project_root): # type: ignore[no-untyped-def] + app = create_app(actor="human:alice", root=project_root) + return TestClient(app, raise_server_exceptions=False) + + +def test_app_boots_and_sets_csrf_cookie(client: TestClient) -> None: + resp = client.get("/healthz") + assert resp.status_code == 200 + assert "pw_csrf" in resp.cookies + + +def test_unknown_path_404(client: TestClient) -> None: + assert client.get("/no-such-page").status_code == 404 + + +def test_plainweave_error_renders_error_partial(project_root) -> None: # type: ignore[no-untyped-def] + """A route that raises PlainweaveError(NOT_FOUND) must render the error partial at 404.""" + + async def boom(request: Request) -> Response: + raise PlainweaveError( + ErrorCode.NOT_FOUND, + "thing not found", + recoverable=False, + hint="check the id", + ) + + app = create_app(actor="human:alice", root=project_root) + # Splice in a test-only route at the front of the router. + app.routes.insert(0, Route("/boom", boom)) + + client = TestClient(app, raise_server_exceptions=False) + resp = client.get("/boom") + assert resp.status_code == 404 + assert "NOT_FOUND" in resp.text + assert "thing not found" in resp.text + assert "check the id" in resp.text + + +def test_csrf_blocks_mutation_without_token(project_root) -> None: # type: ignore[no-untyped-def] + """POST without a valid CSRF token returns 403.""" + app = create_app(actor="human:alice", root=project_root) + client = TestClient(app, raise_server_exceptions=False) + resp = client.post("/healthz", data={"field": "value"}) + assert resp.status_code == 403 + + +def test_csrf_passes_mutation_with_valid_token(project_root) -> None: # type: ignore[no-untyped-def] + """POST with a matching CSRF cookie+form token passes the CSRF gate.""" + from plainweave.web.context import new_csrf_token + + app = create_app(actor="human:alice", root=project_root) + client = TestClient(app, raise_server_exceptions=False) + token = new_csrf_token() + # POST /healthz is not a defined route so it will 405; but the CSRF check + # must pass first (status != 403 means gate was not tripped). + resp = client.post( + "/healthz", + data={"_csrf": token}, + cookies={"pw_csrf": token}, + ) + assert resp.status_code != 403 diff --git a/tests/web/test_errors.py b/tests/web/test_errors.py new file mode 100644 index 0000000..aa0d832 --- /dev/null +++ b/tests/web/test_errors.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from plainweave.errors import ErrorCode +from plainweave.web.errors import error_to_status + + +def test_error_status_mapping() -> None: + assert error_to_status(ErrorCode.VALIDATION) == 400 + assert error_to_status(ErrorCode.NOT_FOUND) == 404 + assert error_to_status(ErrorCode.CONFLICT) == 409 + assert error_to_status(ErrorCode.POLICY_REQUIRED) == 409 + assert error_to_status(ErrorCode.INTERNAL) == 500 diff --git a/uv.lock b/uv.lock index bee3621..287bb0f 100644 --- a/uv.lock +++ b/uv.lock @@ -640,6 +640,7 @@ web = [ [package.dev-dependencies] dev = [ { name = "coverage" }, + { name = "jinja2" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -658,6 +659,7 @@ provides-extras = ["web"] [package.metadata.requires-dev] dev = [ { name = "coverage", extras = ["toml"], specifier = ">=7.0" }, + { name = "jinja2", specifier = ">=3.1.6" }, { name = "mypy", specifier = ">=1.13.0" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov", specifier = ">=5.0" }, From 6f4c41ddeddd12fb17e94662f6655617c1e1e2e8 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:09:38 +1000 Subject: [PATCH 06/29] fix(web): CSRF middleware preserves request body for downstream handlers Replace ``await request.form()`` in the CSRF BaseHTTPMiddleware with ``await request.body()`` + ``parse_qsl`` so Starlette's _CachedRequest can replay the raw bytes to downstream POST handlers. The old form() call consumed the body stream, leaving every downstream request.form() empty. Add regression test (RED->GREEN verified) that splices a /echo route and asserts the submitted field value arrives intact after CSRF validation. Also type the test fixtures properly (drop type:ignore suppressions) and extend test_error_status_mapping to cover all 10 ErrorCode members. Co-Authored-By: Claude Sonnet 4.6 --- src/plainweave/web/app.py | 10 +++++++--- tests/web/test_app.py | 39 ++++++++++++++++++++++++++++++++++----- tests/web/test_errors.py | 5 +++++ 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/plainweave/web/app.py b/src/plainweave/web/app.py index e4cb969..61420ee 100644 --- a/src/plainweave/web/app.py +++ b/src/plainweave/web/app.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from urllib.parse import parse_qsl from starlette.applications import Starlette from starlette.middleware import Middleware @@ -42,9 +43,12 @@ async def on_error(request: Request, exc: Exception) -> Response: async def csrf_mw(request: Request, call_next: RequestResponseEndpoint) -> Response: token = request.cookies.get(_CSRF_COOKIE) if request.method in {"POST", "PUT", "PATCH", "DELETE"}: - form = await request.form() - form_token = str(form.get("_csrf")) if form.get("_csrf") is not None else None - if not csrf_ok(token, form_token): + # Read via .body() so Starlette's _CachedRequest can replay the raw + # bytes to downstream handlers — calling .form() here would consume + # the body stream, leaving downstream request.form() empty. + body = await request.body() + fields = dict(parse_qsl(body.decode("utf-8"))) + if not csrf_ok(token, fields.get("_csrf")): return PlainTextResponse("CSRF check failed", status_code=403) response = await call_next(request) if token is None: diff --git a/tests/web/test_app.py b/tests/web/test_app.py index f27a50e..9c44dae 100644 --- a/tests/web/test_app.py +++ b/tests/web/test_app.py @@ -1,8 +1,10 @@ from __future__ import annotations +from pathlib import Path + import pytest from starlette.requests import Request -from starlette.responses import Response +from starlette.responses import PlainTextResponse, Response from starlette.routing import Route from starlette.testclient import TestClient @@ -11,7 +13,7 @@ @pytest.fixture -def client(project_root): # type: ignore[no-untyped-def] +def client(project_root: Path) -> TestClient: app = create_app(actor="human:alice", root=project_root) return TestClient(app, raise_server_exceptions=False) @@ -26,7 +28,7 @@ def test_unknown_path_404(client: TestClient) -> None: assert client.get("/no-such-page").status_code == 404 -def test_plainweave_error_renders_error_partial(project_root) -> None: # type: ignore[no-untyped-def] +def test_plainweave_error_renders_error_partial(project_root: Path) -> None: """A route that raises PlainweaveError(NOT_FOUND) must render the error partial at 404.""" async def boom(request: Request) -> Response: @@ -49,7 +51,7 @@ async def boom(request: Request) -> Response: assert "check the id" in resp.text -def test_csrf_blocks_mutation_without_token(project_root) -> None: # type: ignore[no-untyped-def] +def test_csrf_blocks_mutation_without_token(project_root: Path) -> None: """POST without a valid CSRF token returns 403.""" app = create_app(actor="human:alice", root=project_root) client = TestClient(app, raise_server_exceptions=False) @@ -57,7 +59,7 @@ def test_csrf_blocks_mutation_without_token(project_root) -> None: # type: igno assert resp.status_code == 403 -def test_csrf_passes_mutation_with_valid_token(project_root) -> None: # type: ignore[no-untyped-def] +def test_csrf_passes_mutation_with_valid_token(project_root: Path) -> None: """POST with a matching CSRF cookie+form token passes the CSRF gate.""" from plainweave.web.context import new_csrf_token @@ -72,3 +74,30 @@ def test_csrf_passes_mutation_with_valid_token(project_root) -> None: # type: i cookies={"pw_csrf": token}, ) assert resp.status_code != 403 + + +def test_csrf_middleware_does_not_consume_form_body(project_root: Path) -> None: + """CSRF middleware must not consume the request body. + + Downstream POST handlers that call request.form() must receive the submitted + form fields, not an empty form. This locks the fix for the body-stream bug + where ``await request.form()`` in BaseHTTPMiddleware consumed the body before + the downstream handler could read it. + """ + from plainweave.web.context import new_csrf_token + + async def echo_field(request: Request) -> Response: + form = await request.form() + value = form.get("field") or "" + return PlainTextResponse(str(value)) + + app = create_app(actor="human:alice", root=project_root) + app.routes.insert(0, Route("/echo", echo_field, methods=["POST"])) + + token = new_csrf_token() + client = TestClient(app, raise_server_exceptions=False) + client.cookies.set("pw_csrf", token) + resp = client.post("/echo", data={"_csrf": token, "field": "hello"}) + # CSRF must pass (not 403) AND downstream form data must be intact (not empty). + assert resp.status_code == 200 + assert resp.text == "hello" diff --git a/tests/web/test_errors.py b/tests/web/test_errors.py index aa0d832..d8dfef9 100644 --- a/tests/web/test_errors.py +++ b/tests/web/test_errors.py @@ -9,4 +9,9 @@ def test_error_status_mapping() -> None: assert error_to_status(ErrorCode.NOT_FOUND) == 404 assert error_to_status(ErrorCode.CONFLICT) == 409 assert error_to_status(ErrorCode.POLICY_REQUIRED) == 409 + assert error_to_status(ErrorCode.LOCKED) == 409 + assert error_to_status(ErrorCode.PEER_ABSENT) == 503 + assert error_to_status(ErrorCode.PEER_STALE) == 503 + assert error_to_status(ErrorCode.PEER_CONTRACT) == 502 + assert error_to_status(ErrorCode.UNSUPPORTED) == 400 assert error_to_status(ErrorCode.INTERNAL) == 500 From 3facd80f47554edc27affc9fd8722aa03a023fbb Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:19:37 +1000 Subject: [PATCH 07/29] feat(conformance): plainweave becomes the 4th SEI conformer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plainweave (ex-charter, 1.0.0) consumes loomweave SEI via loomweave_adapter but was the SEI laggard (no §8 oracle). Add tests/conformance/test_sei_oracle.py: vendor loomweave's sei-conformance-oracle.json BYTE-IDENTICAL (same upstream pin 0ea57702...) + Layer-1 UPSTREAM_BLOB_SHA byte-pin + Layer-2 sei_drift recheck (PLAINWEAVE_SEI_DRIFT_REQUIRED arming, skip-clean absent) + the 6 §8 scenarios driving the REAL LoomweaveAdapter resolve path via injected fetch (non-circular). Load-bearing production fix (loomweave_adapter.py, +32 lines, additive): add _probe_sei_capability() gating the HTTP resolve on the /api/v1/_capabilities sei.supported wire — so capability_absent degrades HONESTLY (reason='unsupported') instead of conflating 'no SEI capability' with 'down' (reason='unreachable'). This closes the §8 capability_absent gap genuinely, not by faking it. All 6 scenarios pass; byte-identical to loomweave authority; byte-pin reds on tamper. Full suite 248 passed (+12 conformance), only the pre-existing stale uv-tool version test fails (unrelated). ruff+mypy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 3 + src/plainweave/loomweave_adapter.py | 32 ++ tests/conformance/fixtures/PROVENANCE.md | 40 ++ .../fixtures/sei-conformance-oracle.json | 85 ++++ tests/conformance/test_sei_oracle.py | 368 ++++++++++++++++++ tests/test_loomweave_adapter.py | 6 + 6 files changed, 534 insertions(+) create mode 100644 tests/conformance/fixtures/PROVENANCE.md create mode 100644 tests/conformance/fixtures/sei-conformance-oracle.json create mode 100644 tests/conformance/test_sei_oracle.py diff --git a/pyproject.toml b/pyproject.toml index eb54599..010320a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,9 @@ explicit_package_bases = true [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-ra -q" +markers = [ + "sei_drift: opt-in release-gate recheck that the vendored SEI conformance oracle has not drifted from the upstream Loomweave checkout (run with -m sei_drift)", +] [tool.coverage.run] source = ["plainweave"] diff --git a/src/plainweave/loomweave_adapter.py b/src/plainweave/loomweave_adapter.py index 584f007..b873068 100644 --- a/src/plainweave/loomweave_adapter.py +++ b/src/plainweave/loomweave_adapter.py @@ -253,7 +253,39 @@ def snapshot_error(self, error: LoomweaveIdentityError) -> JsonObject: code = code_by_reason.get(error.reason, "identity_degraded") return {"code": code, "message": error.message} + def _probe_sei_capability(self) -> None: + """Probe the remote Loomweave's ``GET /api/v1/_capabilities`` and gate the + HTTP identity resolve on ``sei.supported``. + + Loomweave is the SEI authority; a consumer learns whether an instance serves + SEI from the wire capability, NOT from the local SQLite schema. Without this + probe the adapter would POST ``/api/v1/identity/resolve`` against a pre-SEI + Loomweave and surface the response (or its connection error) as + ``not_found`` / ``unreachable`` — conflating "this instance has no SEI + capability" with "this instance is down". That fails §8 ``capability_absent``, + which requires an HONEST degrade. + + Two outcomes are kept ORTHOGONAL, mirroring the existing reason vocabulary: + * ``_http_json`` raises (connection refused / timeout / non-object body) → + the error propagates with ``reason="unreachable"`` — the remote is down, + unchanged from before. + * ``_http_json`` returns a 2xx body whose ``sei.supported`` is not exactly + ``True`` (absent / false / malformed) → raise ``reason="unsupported"`` — + the remote is reachable but serves no SEI. This is the ONLY new behaviour; + it is the honest "identity unavailable" the conformance oracle demands. + """ + body = self._http_json("GET", "/api/v1/_capabilities") + sei = body.get("sei") + supported = isinstance(sei, dict) and sei.get("supported") is True + if not supported: + raise LoomweaveIdentityError( + "unsupported", + "Loomweave instance does not advertise SEI support (/api/v1/_capabilities).", + [self._degraded("sei_support_missing", "Loomweave SEI capability is absent on the remote.")], + ) + def _resolve_identity_http(self, value: str) -> LoomweaveCatalogEntity: + self._probe_sei_capability() if value.startswith(LOOMWEAVE_SEI_PREFIX): quoted = urllib.parse.quote(value, safe="") body = self._http_json("GET", f"/api/v1/identity/sei/{quoted}") diff --git a/tests/conformance/fixtures/PROVENANCE.md b/tests/conformance/fixtures/PROVENANCE.md new file mode 100644 index 0000000..a4880fa --- /dev/null +++ b/tests/conformance/fixtures/PROVENANCE.md @@ -0,0 +1,40 @@ +# Vendored SEI conformance oracle — provenance + +`sei-conformance-oracle.json` in this directory is a **byte-verbatim** copy of +Loomweave's authoritative fixture: + + /home/john/loomweave/docs/federation/fixtures/sei-conformance-oracle.json + (repo path: docs/federation/fixtures/sei-conformance-oracle.json) + +Loomweave is the **producer / authority** for the six-scenario Weft SEI §8 +conformance oracle (cargo gate `sei_conformance_oracle`). Plainweave is a +**consumer** and vendors the fixture so its conformance suite runs offline, +without a live Loomweave. + +## Invariants + +- **Never hand-edit** the vendored copy. Loomweave's oracle is the only author. +- The Layer-1 byte-pin (`UPSTREAM_BLOB_SHA` in + `tests/conformance/test_sei_oracle.py`) reds the default suite on any byte + change, so a tamper or an accidental edit is caught immediately. +- The Layer-2 drift recheck (`pytest -m sei_drift`) byte-compares this copy + against the upstream sibling checkout (`LOOMWEAVE_REPO`, default + `/home/john/loomweave`) — the release-gate drift alarm. + +## Re-vendor procedure + +1. Copy `$LOOMWEAVE_REPO/docs/federation/fixtures/sei-conformance-oracle.json` + byte-verbatim over this file (`cmp` to confirm). +2. Recompute the git blob SHA and update `UPSTREAM_BLOB_SHA` in + `tests/conformance/test_sei_oracle.py` **in the same commit**: + + python -c "import hashlib,sys; d=open(sys.argv[1],'rb').read(); \ + print(hashlib.sha1(b'blob %d\0'%len(d)+d).hexdigest())" \ + tests/conformance/fixtures/sei-conformance-oracle.json + +3. Re-run conformance and conform the consumer + (`src/plainweave/loomweave_adapter.py`) until green; never weaken the + assertions. + +Current vendored blob SHA: `0ea577025d94c028a0f682b7d29765079455718c` +(fixture_version 1, upstream `updated: 2026-06-02`). diff --git a/tests/conformance/fixtures/sei-conformance-oracle.json b/tests/conformance/fixtures/sei-conformance-oracle.json new file mode 100644 index 0000000..0ea5770 --- /dev/null +++ b/tests/conformance/fixtures/sei-conformance-oracle.json @@ -0,0 +1,85 @@ +{ + "_meta": { + "contract": "weft-sei-conformance-oracle", + "standard": "Weft Stable Entity Identity (SEI) conformance standard §8", + "authority": "Loomweave ADR-038 (token/signature/persistence/reserved-namespace); SEI standard (suite-wide)", + "fixture_version": 1, + "stability": "normative", + "token_format_agnostic": true, + "verification": "cargo test -p loomweave-storage --test sei_conformance_oracle", + "updated": "2026-06-02", + "description": "The six shared SEI conformance scenarios every Weft tool runs against a reference Loomweave. Asserts BEHAVIOUR and OPACITY, never the SEI's internal form. A subsystem is SEI-conformant only when it passes all six (no grandfathering)." + }, + "invariants": [ + "SEI is opaque: a consumer never parses it. It carries the reserved `loomweave:eid:` prefix and is NOT a locator.", + "Fail-closed: when sameness cannot be PROVEN, mint a new SEI and orphan the old one — never silently re-point.", + "Lineage is append-only: born / locator_changed / moved / orphaned / superseded.", + "Identity is carried (never re-minted) for an unchanged locator; SEI values are not part of the byte-identical-run guarantee, but carry/mint decisions are deterministic given the same bindings + source." + ], + "scenarios": [ + { + "id": "identity_round_trip_and_opacity", + "given": "A function entity is analyzed for the first time.", + "when": "Mint an SEI; resolve(locator) → sei; resolve_sei(sei) → locator.", + "expect": { + "resolve_locator": { "sei": "", "current_locator": "", "content_hash": "", "alive": true }, + "resolve_sei": { "current_locator": "", "alive": true }, + "opacity": "the returned `sei` begins with `loomweave:eid:` and is treated as an opaque string by the consumer (never parsed); it is not equal to the locator" + } + }, + { + "id": "rename", + "given": "An entity with an alive SEI; its file/module is renamed so the locator prefix changes; the body is byte-identical; a git-rename signal maps old_locator → new_locator.", + "when": "Re-index.", + "expect": { + "carry": true, + "sei": "unchanged (same token as before)", + "current_locator": "the new locator", + "lineage_appends": "locator_changed", + "resolve_locator(old)": { "alive": false }, + "resolve_locator(new)": { "alive": true, "sei": "" } + } + }, + { + "id": "move", + "given": "An entity with an alive SEI is moved to a new module; body hash AND signature are identical at the new locator; exactly one vanished candidate matches; no git signal required.", + "when": "Re-index.", + "expect": { + "carry": true, + "sei": "unchanged", + "lineage_appends": "moved" + } + }, + { + "id": "ambiguous", + "given": "An entity is renamed WITH a body edit (the body hash changes), even if a git-rename signal is present.", + "when": "Re-index.", + "expect": { + "carry": false, + "new_entity": "minted a fresh SEI (born)", + "old_binding": "orphaned (resolve_sei → alive:false with an `orphaned` lineage event)", + "rationale": "the matcher cannot PROVE sameness → fail closed; a governance attestation on the old SEI is never silently carried across an unproven match" + } + }, + { + "id": "delete", + "given": "An entity present in a prior run is absent from the current run and was not rematched by a rename/move.", + "when": "Re-index.", + "expect": { + "old_binding": "orphaned", + "resolve_locator(old)": { "alive": false }, + "resolve_sei(old_sei)": { "alive": false, "lineage": "includes an `orphaned` event" } + } + }, + { + "id": "capability_absent", + "given": "A Loomweave instance that has not populated SEI (pre-SEI DB, or `_capabilities.sei.supported` false / absent).", + "when": "A consumer probes `_capabilities` and/or resolves.", + "expect": { + "consumer": "detects the absent capability and DEGRADES gracefully — keeps working on locators, no crash, honest 'identity unavailable'", + "resolve_locator(any)": { "alive": false }, + "resolve_sei(unknown)": { "alive": false, "lineage": [] } + } + } + ] +} diff --git a/tests/conformance/test_sei_oracle.py b/tests/conformance/test_sei_oracle.py new file mode 100644 index 0000000..cd66c24 --- /dev/null +++ b/tests/conformance/test_sei_oracle.py @@ -0,0 +1,368 @@ +"""Weft SEI §8 conformance oracle — Plainweave as consumer. + +Plainweave consumes Loomweave's Stable Entity Identity (SEI) via +:class:`plainweave.loomweave_adapter.LoomweaveAdapter`. This module proves +Plainweave is a §8 SEI CONFORMER: each of the six shared scenarios is driven +through the REAL adapter HTTP resolve path (``resolve_identity`` → +``_resolve_identity_http``) with a fake ``_http_json`` injected at the same seam +the existing ``test_identity_resolution_over_http_*`` tests use. The assertions +check the consumer's own verdict — alive / orphaned / unsupported / opacity — +NOT a re-implementation of the oracle (NON-CIRCULAR). + +The scenario list is loaded from the vendored ``sei-conformance-oracle.json`` +fixture, copied BYTE-VERBATIM from Loomweave's authoritative fixture. Loomweave +is the PRODUCER/authority for the six-scenario §8 oracle; Plainweave is the +CONSUMER. Two layers protect the vendored bytes: + + * Layer 1 (default suite): ``UPSTREAM_BLOB_SHA`` git-blob byte-pin — any edit + to the vendored copy reds the default PR suite. + * Layer 2 (opt-in, ``-m sei_drift``): byte-compare against the sibling + Loomweave checkout — the release-gate drift alarm; skips clean when the + sibling is absent. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from collections.abc import Callable +from pathlib import Path +from typing import Any, cast + +import pytest +from tests.loomweave_test_utils import seed_loomweave_catalog + +from plainweave.loomweave_adapter import LoomweaveAdapter, LoomweaveIdentityError + +ORACLE_PATH = Path(__file__).parent / "fixtures" / "sei-conformance-oracle.json" + +# The git blob hash of the vendored SEI conformance oracle as authored upstream by +# Loomweave (docs/federation/fixtures/sei-conformance-oracle.json). Loomweave is the +# PRODUCER/authority for the six-scenario §8 oracle; Plainweave is the CONSUMER and +# VENDORS the fixture byte-verbatim. This Layer-1 byte-pin runs in the DEFAULT suite, +# so ANY byte change to the vendored copy fails loudly — re-vendors are deliberate and +# update this constant in the SAME commit as the new bytes. +# +# RE-VENDOR PROCEDURE (run ``pytest -m sei_drift -v`` before every release; on drift, or +# on a deliberate upstream oracle bump): +# 1. Copy ``$LOOMWEAVE_REPO/docs/federation/fixtures/sei-conformance-oracle.json`` +# byte-verbatim over the vendored copy (``cmp`` to confirm). NEVER hand-edit the +# vendored fixture; Loomweave's oracle (cargo gate ``sei_conformance_oracle``) is the +# only author. +# 2. Update ``UPSTREAM_BLOB_SHA`` to ``git hash-object`` of the vendored file +# (equivalently ``hashlib.sha1(b"blob %d\0" % len(data) + data)``) — same commit. +# 3. Re-run conformance and CONFORM the consumer (``plainweave.loomweave_adapter``) +# until green; never weaken the assertions. +UPSTREAM_BLOB_SHA = "0ea577025d94c028a0f682b7d29765079455718c" + +CAPABILITIES_PATH = "/api/v1/_capabilities" +SEI_SUPPORTED_CAPS: dict[str, Any] = {"sei": {"supported": True, "version": 1}} + + +def _load_oracle() -> dict[str, Any]: + return cast("dict[str, Any]", json.loads(ORACLE_PATH.read_text(encoding="utf-8"))) + + +def _scenario(scenario_id: str) -> dict[str, Any]: + for item in _load_oracle()["scenarios"]: + if item["id"] == scenario_id: + return cast("dict[str, Any]", item) + raise AssertionError(f"missing SEI oracle scenario {scenario_id!r}") + + +def _loomweave_oracle_source() -> Path | None: + # Env takes EXCLUSIVE precedence: if ``LOOMWEAVE_REPO`` is set, resolve the sibling + # ONLY from it and skip clean if the oracle is absent under it. Otherwise fall back + # to the documented local-dev convenience checkout at ``/home/john/loomweave``. A + # CI runner (env unset, no convenience sibling) skips clean — the documented basis + # for the clean skip is the sibling's ABSENCE, not a guarantee independent of layout. + subpath = ("docs", "federation", "fixtures", "sei-conformance-oracle.json") + env = os.environ.get("LOOMWEAVE_REPO") + if env: + path = Path(env).joinpath(*subpath) + return path if path.exists() else None + path = Path("/home/john/loomweave").joinpath(*subpath) + return path if path.exists() else None + + +COVERED_SCENARIOS = { + "identity_round_trip_and_opacity", + "rename", + "move", + "ambiguous", + "delete", + "capability_absent", +} + + +def _fake_http_json( + *, + caps: dict[str, Any] | None, + identity: dict[str, Any] | None, + calls: list[str], +) -> Callable[..., dict[str, object]]: + """Build a fake ``_http_json`` that records every wire path and routes the + ``_capabilities`` probe to ``caps`` and any identity resolve to ``identity``.""" + + def fake(method: str, path: str, payload: object | None = None) -> dict[str, object]: + calls.append(path) + if path == CAPABILITIES_PATH: + if caps is None: + # Model a remote that 2xx-returns a body with no SEI capability. + return {} + return cast("dict[str, object]", caps) + assert identity is not None, f"unexpected identity wire call: {path}" + return cast("dict[str, object]", identity) + + return fake + + +# --------------------------------------------------------------------------- # +# Fixture integrity (Layer 1 + Layer 2) # +# --------------------------------------------------------------------------- # + + +def test_vendored_oracle_matches_upstream_blob_pin() -> None: + """Layer 1 (default suite): the vendored SEI oracle byte-pins to the upstream + git blob hash. ANY edit to the vendored fixture without a matching re-pin reds + the default suite — the fail-closed protection that lets the Layer-2 drift + recheck skip clean when the sibling checkout is absent.""" + assert len(UPSTREAM_BLOB_SHA) == 40 and set(UPSTREAM_BLOB_SHA) <= set("0123456789abcdef"), ( + f"UPSTREAM_BLOB_SHA must be 40 lowercase hex chars (a git blob SHA-1): {UPSTREAM_BLOB_SHA!r}" + ) + data = ORACLE_PATH.read_bytes() + actual = hashlib.sha1(b"blob %d\x00" % len(data) + data).hexdigest() + assert actual == UPSTREAM_BLOB_SHA, ( + f"the vendored SEI oracle changed (git blob {actual}, pinned {UPSTREAM_BLOB_SHA}) — " + "if this was a deliberate re-vendor, update UPSTREAM_BLOB_SHA in the same commit and " + "re-run conformance; if not, someone edited the vendored copy (forbidden — Loomweave's " + "oracle is the only author; see the RE-VENDOR PROCEDURE at the top of this module)" + ) + + +@pytest.mark.sei_drift +def test_vendored_oracle_matches_loomweave_source() -> None: + """Layer 2 (opt-in, ``-m sei_drift``): the sibling Loomweave checkout's + authoritative oracle must be BYTE-IDENTICAL to the vendored copy — the + release-gate drift alarm. Absent checkout (CI/default suite) skips clean; + divergence FAILS. + + FAIL-CLOSED ARMING: a release gate sets ``PLAINWEAVE_SEI_DRIFT_REQUIRED`` to + turn the skip into a HARD FAILURE when the sibling oracle is missing — so an + armed drift gate cannot silently no-op (e.g. a runner that forgot to provide + ``LOOMWEAVE_REPO``). Unset (the default) keeps the skip-clean behaviour. + + Byte-exact (not JSON-semantic) by design: the RE-VENDOR PROCEDURE mandates a + byte-verbatim copy and the Layer-1 ``UPSTREAM_BLOB_SHA`` pins the git blob, so + a copy that is reordered/reformatted (JSON-equal but byte-different) would leave + the blob-pin silently stale yet pass a parsed-dict compare. Comparing raw bytes + enforces the same byte-verbatim invariant Layer-1 assumes.""" + source = _loomweave_oracle_source() + if source is None: + if os.environ.get("PLAINWEAVE_SEI_DRIFT_REQUIRED"): + pytest.fail( + "SEI drift check ARMED (PLAINWEAVE_SEI_DRIFT_REQUIRED) but no Loomweave sibling " + "oracle was found — point LOOMWEAVE_REPO at a checkout that carries " + "docs/federation/fixtures/sei-conformance-oracle.json so drift can be proven" + ) + pytest.skip("Loomweave repo not found; set LOOMWEAVE_REPO to enable the drift check") + if ORACLE_PATH.read_bytes() != source.read_bytes(): + pytest.fail( + f"upstream {source} has drifted from the vendored " + "tests/conformance/fixtures/sei-conformance-oracle.json — re-vendor + conform: follow " + "the RE-VENDOR PROCEDURE at the top of this module (byte-verbatim copy, bump " + "UPSTREAM_BLOB_SHA in the same commit, re-run conformance)" + ) + + +def test_every_oracle_scenario_is_covered() -> None: + fixture_ids = {item["id"] for item in _load_oracle()["scenarios"]} + assert fixture_ids == COVERED_SCENARIOS + + +# --------------------------------------------------------------------------- # +# The six §8 scenarios, driven through the REAL adapter HTTP resolve path # +# --------------------------------------------------------------------------- # + + +def test_identity_round_trip_and_opacity(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """round_trip: resolve(locator) → sei; resolve(sei) → locator; the SEI is opaque + (carries the reserved prefix, is never equal to the locator, never parsed).""" + scenario = _scenario("identity_round_trip_and_opacity") + seed = seed_loomweave_catalog(tmp_path) + locator = seed["public_locator"] + sei = seed["public_sei"] + monkeypatch.setenv("WEFT_LOOMWEAVE_URL", "http://loomweave.test") + adapter = LoomweaveAdapter(tmp_path) + calls: list[str] = [] + monkeypatch.setattr( + adapter, + "_http_json", + _fake_http_json( + caps=SEI_SUPPORTED_CAPS, + identity={"alive": True, "current_locator": locator, "sei": sei, "content_hash": "hash-public-v1"}, + calls=calls, + ), + ) + + by_locator = adapter.resolve_identity(locator) + by_sei = adapter.resolve_identity(sei) + + assert scenario["expect"]["resolve_locator"]["alive"] is True + # resolve(locator) → sei + assert by_locator.locator == locator + assert by_locator.sei == sei + assert by_locator.lineage_status == "alive" + # resolve(sei) → locator + assert by_sei.locator == locator + assert by_sei.sei == sei + # opacity: reserved prefix, not equal to the locator (the consumer never parses it). + assert by_locator.sei is not None and by_locator.sei.startswith("loomweave:eid:") + assert by_locator.sei != locator + assert CAPABILITIES_PATH in calls + + +@pytest.mark.parametrize("scenario_id", ["rename", "move"]) +def test_carried_sei_remains_alive_for_rename_and_move( + scenario_id: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """rename / move: identity is CARRIED — the same SEI resolves alive (at the new + locator), so the consumer's verdict stays ALIVE across the re-index.""" + scenario = _scenario(scenario_id) + seed = seed_loomweave_catalog(tmp_path) + new_locator = seed["public_locator"] + carried_sei = seed["public_sei"] + monkeypatch.setenv("WEFT_LOOMWEAVE_URL", "http://loomweave.test") + adapter = LoomweaveAdapter(tmp_path) + calls: list[str] = [] + monkeypatch.setattr( + adapter, + "_http_json", + _fake_http_json( + caps=SEI_SUPPORTED_CAPS, + identity={ + "alive": True, + "current_locator": new_locator, + "sei": carried_sei, + "content_hash": "hash-public-v1", + }, + calls=calls, + ), + ) + + resolved = adapter.resolve_identity(carried_sei) + + assert scenario["expect"]["carry"] is True + assert resolved.sei == carried_sei # carried verbatim — unchanged token + assert resolved.locator == new_locator + assert resolved.lineage_status == "alive" + + +@pytest.mark.parametrize("scenario_id", ["ambiguous", "delete"]) +def test_orphaned_sei_surfaces_as_orphaned_for_ambiguous_and_delete( + scenario_id: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """ambiguous / delete: the old binding is ORPHANED — resolve_sei returns + ``alive:false`` with an ``orphaned`` lineage event, so the consumer raises the + ``orphaned`` verdict (fail-closed: never silently re-pointed).""" + scenario = _scenario(scenario_id) + seed = seed_loomweave_catalog(tmp_path) + orphaned_sei = seed["public_sei"] + monkeypatch.setenv("WEFT_LOOMWEAVE_URL", "http://loomweave.test") + adapter = LoomweaveAdapter(tmp_path) + calls: list[str] = [] + monkeypatch.setattr( + adapter, + "_http_json", + _fake_http_json( + caps=SEI_SUPPORTED_CAPS, + identity={"alive": False, "lineage": [{"event": "orphaned"}]}, + calls=calls, + ), + ) + + assert "orphaned" in json.dumps(scenario["expect"]) + with pytest.raises(LoomweaveIdentityError) as exc_info: + adapter.resolve_identity(orphaned_sei) + + assert exc_info.value.reason == "orphaned" + + +@pytest.mark.parametrize( + "caps", + [ + pytest.param({"sei": {"supported": False}}, id="supported_false"), + pytest.param({}, id="sei_key_absent"), + pytest.param({"sei": {"version": 1}}, id="supported_key_absent"), + ], +) +def test_capability_absent_degrades_honestly( + caps: dict[str, Any], tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """capability_absent: a REACHABLE remote whose ``_capabilities.sei.supported`` is + false OR ABSENT (the §8 scenario names both) → the consumer detects the absent + capability and degrades HONESTLY to ``unsupported`` (identity unavailable) — NOT + ``unreachable`` (down), and NEVER a fabricated alive identity. The identity resolve + route is never even called.""" + scenario = _scenario("capability_absent") + seed = seed_loomweave_catalog(tmp_path) + locator = seed["public_locator"] + monkeypatch.setenv("WEFT_LOOMWEAVE_URL", "http://loomweave.test") + adapter = LoomweaveAdapter(tmp_path) + calls: list[str] = [] + monkeypatch.setattr( + adapter, + "_http_json", + # Reachable remote (the probe 2xx-returns), but SEI is not supported / not advertised. + _fake_http_json(caps=caps, identity=None, calls=calls), + ) + + assert "DEGRADES gracefully" in json.dumps(scenario["expect"]) + with pytest.raises(LoomweaveIdentityError) as exc_info: + adapter.resolve_identity(locator) + + # Honest degrade: capability-absent, NOT a down/unreachable verdict. + assert exc_info.value.reason == "unsupported" + assert exc_info.value.reason != "unreachable" + # The consumer probed _capabilities and STOPPED — it never resolved against a + # remote it knows serves no SEI (no fabricated identity). + assert calls == [CAPABILITIES_PATH] + + +def test_capability_absent_distinguished_from_unreachable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The split that makes capability_absent honest: a DOWN remote (the probe itself + raises) still surfaces ``unreachable``, while a REACHABLE pre-SEI remote surfaces + ``unsupported``. Conflating the two is exactly the §8 capability_absent failure.""" + seed = seed_loomweave_catalog(tmp_path) + locator = seed["public_locator"] + monkeypatch.setenv("WEFT_LOOMWEAVE_URL", "http://loomweave.test") + + # (a) Remote DOWN: the probe raises before any capability is read → unreachable. + down = LoomweaveAdapter(tmp_path) + + def probe_raises(method: str, path: str, payload: object | None = None) -> dict[str, object]: + raise LoomweaveIdentityError( + "unreachable", + "down", + [{"code": "identity_unreachable", "message": "down"}], + ) + + monkeypatch.setattr(down, "_http_json", probe_raises) + with pytest.raises(LoomweaveIdentityError) as down_exc: + down.resolve_identity(locator) + assert down_exc.value.reason == "unreachable" + + # (b) Remote REACHABLE but pre-SEI → unsupported (the honest capability_absent). + absent = LoomweaveAdapter(tmp_path) + calls: list[str] = [] + monkeypatch.setattr( + absent, + "_http_json", + _fake_http_json(caps={"sei": {"supported": False}}, identity=None, calls=calls), + ) + with pytest.raises(LoomweaveIdentityError) as absent_exc: + absent.resolve_identity(locator) + assert absent_exc.value.reason == "unsupported" diff --git a/tests/test_loomweave_adapter.py b/tests/test_loomweave_adapter.py index 2bd1f63..272bab4 100644 --- a/tests/test_loomweave_adapter.py +++ b/tests/test_loomweave_adapter.py @@ -224,6 +224,8 @@ def test_identity_resolution_over_http_returns_alive_snapshot( } def fake_http_json(method: str, path: str, payload: object | None = None) -> dict[str, object]: + if path == "/api/v1/_capabilities": + return {"sei": {"supported": True, "version": 1}} return alive_body monkeypatch.setattr(adapter, "_http_json", fake_http_json) @@ -249,6 +251,8 @@ def test_identity_resolution_over_http_matches_the_pinned_contract_fixture( resolve_body = cast("dict[str, object]", _identity_fixture("identity-resolve.json")["response"]) def fake_http_json(method: str, path: str, payload: object | None = None) -> dict[str, object]: + if path == "/api/v1/_capabilities": + return {"sei": {"supported": True, "version": 1}} return sei_body if path.startswith("/api/v1/identity/sei/") else resolve_body monkeypatch.setattr(adapter, "_http_json", fake_http_json) @@ -272,6 +276,8 @@ def test_identity_resolution_over_http_reports_orphaned_when_not_alive( adapter = LoomweaveAdapter(tmp_path) def fake_http_json(method: str, path: str, payload: object | None = None) -> dict[str, object]: + if path == "/api/v1/_capabilities": + return {"sei": {"supported": True, "version": 1}} return {"alive": False, "lineage": [{"event": "renamed"}]} monkeypatch.setattr(adapter, "_http_json", fake_http_json) From 004ed203b2316c8a20373ecbc6a6926b106f916d Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:20:49 +1000 Subject: [PATCH 08/29] feat(web): corpus browse with search/status/orphan filters Co-Authored-By: Claude Sonnet 4.6 --- src/plainweave/web/routes/requirements.py | 55 ++++- .../templates/_partials/corpus_filter.html | 27 +++ .../web/templates/_partials/corpus_rows.html | 11 + src/plainweave/web/templates/corpus.html | 15 ++ src/plainweave/web/views.py | 63 +++++ tests/web/test_requirements.py | 216 ++++++++++++++++++ 6 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 src/plainweave/web/templates/_partials/corpus_filter.html create mode 100644 src/plainweave/web/templates/_partials/corpus_rows.html create mode 100644 src/plainweave/web/templates/corpus.html create mode 100644 src/plainweave/web/views.py create mode 100644 tests/web/test_requirements.py diff --git a/src/plainweave/web/routes/requirements.py b/src/plainweave/web/routes/requirements.py index 03ac990..d0835fe 100644 --- a/src/plainweave/web/routes/requirements.py +++ b/src/plainweave/web/routes/requirements.py @@ -1,6 +1,59 @@ from __future__ import annotations from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Route +from starlette.templating import Jinja2Templates +from plainweave.web import views -def register(app: Starlette) -> None: ... + +def _resolve_titles(ctx: object) -> dict[str, str]: + """Resolve a display title for each requirement. + + For approved requirements the approved-version title is used. For draft-only + requirements the active draft title is sourced via ``requirement_dossier`` so + the displayed title matches what the author typed, not just the display-id + fallback. Falls back to the display-id when neither exists. + """ + from plainweave.service import PlainweaveService # local import avoids circular dep at module load + + svc: PlainweaveService = ctx.service # type: ignore[attr-defined] + records = svc.search_requirements() + titles: dict[str, str] = {} + for rec in records: + if rec.current_version_record is not None: + titles[rec.requirement_id] = rec.current_version_record.title + else: + # Draft-only: fetch the active draft title from the dossier. + dossier = svc.requirement_dossier(rec.requirement_id) + draft = dossier.requirement.active_draft + titles[rec.requirement_id] = draft.title if draft is not None else rec.id + return titles + + +async def corpus(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + q = request.query_params.get("q", "") + status = request.query_params.get("status", "") + orphan = request.query_params.get("orphan", "") + titles = _resolve_titles(ctx) + rows = views.build_corpus_rows(ctx.service.intent_corpus(), ctx.service.search_requirements(), titles) + rows = views.filter_rows(rows, q=q, status=status, orphan=orphan) + template = "_partials/corpus_rows.html" if request.headers.get("HX-Request") else "corpus.html" + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + template, + { + "rows": rows, + "filters": {"q": q, "status": status, "orphan": orphan}, + "operator": ctx.operator, + "active_page": "corpus", + }, + ) + + +def register(app: Starlette) -> None: + app.router.routes.append(Route("/", corpus, name="corpus")) diff --git a/src/plainweave/web/templates/_partials/corpus_filter.html b/src/plainweave/web/templates/_partials/corpus_filter.html new file mode 100644 index 0000000..f2b974c --- /dev/null +++ b/src/plainweave/web/templates/_partials/corpus_filter.html @@ -0,0 +1,27 @@ + +
+ + + +
+ Status + {% for value, label in [("", "All"), ("approved", "Approved"), ("draft", "Draft"), ("deprecated", "Deprecated")] %} + + {% endfor %} +
+ +
+ Orphans + {% for value, label in [("", "Any"), ("no-goal", "No goal"), ("no-code", "No code"), ("both", "Both")] %} + + {% endfor %} +
+
+
diff --git a/src/plainweave/web/templates/_partials/corpus_rows.html b/src/plainweave/web/templates/_partials/corpus_rows.html new file mode 100644 index 0000000..22e69d2 --- /dev/null +++ b/src/plainweave/web/templates/_partials/corpus_rows.html @@ -0,0 +1,11 @@ +{% for row in rows %} + + {{ row.title }} {{ row.display_id }} + {{ row.status }} + {% if row.goal_count %}{{ row.goal_count }}{% else %}none{% endif %} + {% if row.code_count %}{{ row.code_count }}{% else %}none{% endif %} + +
+{% else %} +No requirements match the current filters. +{% endfor %} diff --git a/src/plainweave/web/templates/corpus.html b/src/plainweave/web/templates/corpus.html new file mode 100644 index 0000000..c2619bc --- /dev/null +++ b/src/plainweave/web/templates/corpus.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% set active_page = "corpus" %} +{% block title %}Corpus · Plainweave{% endblock %} +{% block main %} +

Corpus

+{% include "_partials/corpus_filter.html" %} + + + + + + {% include "_partials/corpus_rows.html" %} + +
RequirementStatusGoalCode links
+{% endblock %} diff --git a/src/plainweave/web/views.py b/src/plainweave/web/views.py new file mode 100644 index 0000000..f120ba0 --- /dev/null +++ b/src/plainweave/web/views.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from plainweave.intent_graph import CorpusEntry +from plainweave.models import RequirementRecord + + +@dataclass(frozen=True) +class CorpusRow: + req_id: str + display_id: str + title: str + status: str + goal_count: int + code_count: int + + +def build_corpus_rows( + corpus: list[CorpusEntry], + records: list[RequirementRecord], + titles: dict[str, str], +) -> list[CorpusRow]: + """Build corpus rows from pre-fetched corpus entries, records, and resolved titles. + + ``titles`` maps requirement_id -> resolved title (caller resolves draft vs version title + before calling here, keeping this function pure and unit-testable). + """ + by_id = {r.requirement_id: r for r in records} + rows: list[CorpusRow] = [] + for entry in corpus: + rid = entry.requirement.node_id + rec = by_id.get(rid) + if rec is None: + continue + title = titles.get(rid, rec.id) + rows.append( + CorpusRow( + req_id=rid, + display_id=rec.id, + title=title, + status=rec.status, + goal_count=len(entry.goals), + code_count=len(entry.code), + ) + ) + return rows + + +def filter_rows(rows: list[CorpusRow], *, q: str, status: str, orphan: str) -> list[CorpusRow]: + out = rows + if q: + needle = q.lower() + out = [r for r in out if needle in r.title.lower() or needle in r.display_id.lower()] + if status: + out = [r for r in out if r.status == status] + if orphan == "no-goal": + out = [r for r in out if r.goal_count == 0] + elif orphan == "no-code": + out = [r for r in out if r.code_count == 0] + elif orphan == "both": + out = [r for r in out if r.goal_count == 0 and r.code_count == 0] + return out diff --git a/tests/web/test_requirements.py b/tests/web/test_requirements.py new file mode 100644 index 0000000..1cd0612 --- /dev/null +++ b/tests/web/test_requirements.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +from plainweave.intent_graph import CorpusEntry, IntentLevel, IntentNode +from plainweave.models import RequirementRecord, RequirementVersion +from plainweave.web import views +from plainweave.web.app import create_app +from plainweave.web.views import CorpusRow + + +@pytest.fixture +def client(project_root: Path) -> TestClient: + return TestClient(create_app(actor="human:alice", root=project_root)) + + +def _mint(client: TestClient, title: str, statement: str) -> Any: + # Author a draft requirement through the (later) new-req route; for this test + # seed via the service directly to keep the test focused on rendering. + app: Starlette = client.app # type: ignore[assignment] + ctx = app.state.ctx_factory() + return ctx.service.create_requirement(title, statement, actor="human:alice") + + +def test_corpus_lists_requirements(client: TestClient) -> None: + _mint(client, "Coverage is self-computable", "answers why this exists") + resp = client.get("/") + assert resp.status_code == 200 + assert "Coverage is self-computable" in resp.text + + +def test_corpus_orphan_filter_no_goal(client: TestClient) -> None: + _mint(client, "Orphan req", "no goal yet") + resp = client.get("/", params={"orphan": "no-goal"}) + assert "Orphan req" in resp.text + # status filter excludes it + resp2 = client.get("/", params={"status": "approved"}) + assert "Orphan req" not in resp2.text + + +def test_corpus_status_filter(client: TestClient) -> None: + _mint(client, "Draft only req", "a draft") + resp = client.get("/", params={"status": "draft"}) + assert resp.status_code == 200 + assert "Draft only req" in resp.text + + +def test_corpus_search_query(client: TestClient) -> None: + _mint(client, "Unique title XYZ", "some statement") + _mint(client, "Another req", "different") + resp = client.get("/", params={"q": "XYZ"}) + assert "Unique title XYZ" in resp.text + assert "Another req" not in resp.text + + +def test_corpus_htmx_partial(client: TestClient) -> None: + _mint(client, "HTMX req", "for htmx test") + resp = client.get("/", headers={"HX-Request": "true"}) + assert resp.status_code == 200 + # Should not include the full page chrome + assert " CorpusRow: + return CorpusRow( + req_id="req-1", + display_id=display_id, + title=title, + status=status, + goal_count=goal_count, + code_count=code_count, + ) + + +def test_filter_rows_empty_filters() -> None: + rows = [_row("A"), _row("B")] + assert views.filter_rows(rows, q="", status="", orphan="") == rows + + +def test_filter_rows_q_by_title() -> None: + rows = [_row("Coverage metric"), _row("Other req")] + result = views.filter_rows(rows, q="coverage", status="", orphan="") + assert len(result) == 1 + assert result[0].title == "Coverage metric" + + +def test_filter_rows_q_by_display_id() -> None: + rows = [ + CorpusRow("req-1", "REQ-PROJ-0001", "Some title", "draft", 0, 0), + CorpusRow("req-2", "REQ-PROJ-0002", "Another", "draft", 0, 0), + ] + result = views.filter_rows(rows, q="0001", status="", orphan="") + assert len(result) == 1 + assert result[0].display_id == "REQ-PROJ-0001" + + +def test_filter_rows_status() -> None: + rows = [_row(status="draft"), _row(status="approved")] + result = views.filter_rows(rows, q="", status="approved", orphan="") + assert len(result) == 1 + assert result[0].status == "approved" + + +def test_filter_rows_orphan_no_goal() -> None: + rows = [_row(goal_count=0, code_count=1), _row(goal_count=2, code_count=0)] + result = views.filter_rows(rows, q="", status="", orphan="no-goal") + assert len(result) == 1 + assert result[0].goal_count == 0 + + +def test_filter_rows_orphan_no_code() -> None: + rows = [_row(goal_count=1, code_count=0), _row(goal_count=0, code_count=2)] + result = views.filter_rows(rows, q="", status="", orphan="no-code") + assert len(result) == 1 + assert result[0].code_count == 0 + + +def test_filter_rows_orphan_both() -> None: + rows = [ + _row(goal_count=0, code_count=0), + _row(goal_count=1, code_count=0), + _row(goal_count=0, code_count=1), + ] + result = views.filter_rows(rows, q="", status="", orphan="both") + assert len(result) == 1 + assert result[0].goal_count == 0 + assert result[0].code_count == 0 + + +def test_filter_rows_combined() -> None: + rows = [ + _row(title="Target", status="draft", goal_count=0), + _row(title="Target", status="approved", goal_count=0), + _row(title="Other", status="draft", goal_count=0), + ] + result = views.filter_rows(rows, q="target", status="draft", orphan="no-goal") + assert len(result) == 1 + assert result[0].title == "Target" + assert result[0].status == "draft" + + +# --- Unit tests for build_corpus_rows --- + + +def test_build_corpus_rows_draft_uses_provided_title() -> None: + """Draft-only requirement uses the title from the resolved titles dict.""" + entry = CorpusEntry( + requirement=IntentNode(IntentLevel.REQUIREMENT, "req-1"), + goals=(), + code=(), + ) + record = RequirementRecord( + requirement_id="req-1", + id="REQ-TEST-0001", + stable_id="plainweave:req:test:0001", + current_version=0, + active_draft_id="DRAFT-0001", + status="draft", + current_version_record=None, + ) + titles = {"req-1": "My Draft Title"} + rows = views.build_corpus_rows([entry], [record], titles) + assert len(rows) == 1 + assert rows[0].title == "My Draft Title" + assert rows[0].goal_count == 0 + assert rows[0].code_count == 0 + + +def test_build_corpus_rows_approved_uses_version_title() -> None: + """Approved requirement uses the title from the titles dict (resolved from version).""" + version = RequirementVersion( + requirement_id="req-2", + id="REQ-TEST-0002", + stable_id="plainweave:req:test:0002", + version=1, + title="Approved Title", + statement="approved statement", + statement_hash="abc", + status="approved", + approved_by="human:alice", + approved_at="2026-01-01T00:00:00", + ) + entry = CorpusEntry( + requirement=IntentNode(IntentLevel.REQUIREMENT, "req-2"), + goals=(IntentNode(IntentLevel.GOAL, "goal-1"),), + code=(IntentNode(IntentLevel.CODE, "code-1"), IntentNode(IntentLevel.CODE, "code-2")), + ) + record = RequirementRecord( + requirement_id="req-2", + id="REQ-TEST-0002", + stable_id="plainweave:req:test:0002", + current_version=1, + active_draft_id=None, + status="approved", + current_version_record=version, + ) + titles = {"req-2": "Approved Title"} + rows = views.build_corpus_rows([entry], [record], titles) + assert len(rows) == 1 + assert rows[0].title == "Approved Title" + assert rows[0].goal_count == 1 + assert rows[0].code_count == 2 From 1ae3733396f7bc15d73000a6c1c1367929b0a8e6 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:24:57 +1000 Subject: [PATCH 09/29] refactor(web): fix approved-title coverage + eliminate double search_requirements call - _resolve_titles now takes (svc, records) instead of ctx, removing the duplicate search_requirements() call in corpus() - Add test_corpus_approved_req_shows_version_title to cover the approved version title path in _resolve_titles (requirements.py now 100% covered) Co-Authored-By: Claude Sonnet 4.6 --- src/plainweave/web/routes/requirements.py | 13 ++++++------- tests/web/test_requirements.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/plainweave/web/routes/requirements.py b/src/plainweave/web/routes/requirements.py index d0835fe..f7b93ba 100644 --- a/src/plainweave/web/routes/requirements.py +++ b/src/plainweave/web/routes/requirements.py @@ -6,10 +6,12 @@ from starlette.routing import Route from starlette.templating import Jinja2Templates +from plainweave.models import RequirementRecord +from plainweave.service import PlainweaveService from plainweave.web import views -def _resolve_titles(ctx: object) -> dict[str, str]: +def _resolve_titles(svc: PlainweaveService, records: list[RequirementRecord]) -> dict[str, str]: """Resolve a display title for each requirement. For approved requirements the approved-version title is used. For draft-only @@ -17,10 +19,6 @@ def _resolve_titles(ctx: object) -> dict[str, str]: the displayed title matches what the author typed, not just the display-id fallback. Falls back to the display-id when neither exists. """ - from plainweave.service import PlainweaveService # local import avoids circular dep at module load - - svc: PlainweaveService = ctx.service # type: ignore[attr-defined] - records = svc.search_requirements() titles: dict[str, str] = {} for rec in records: if rec.current_version_record is not None: @@ -38,8 +36,9 @@ async def corpus(request: Request) -> Response: q = request.query_params.get("q", "") status = request.query_params.get("status", "") orphan = request.query_params.get("orphan", "") - titles = _resolve_titles(ctx) - rows = views.build_corpus_rows(ctx.service.intent_corpus(), ctx.service.search_requirements(), titles) + records = ctx.service.search_requirements() + titles = _resolve_titles(ctx.service, records) + rows = views.build_corpus_rows(ctx.service.intent_corpus(), records, titles) rows = views.filter_rows(rows, q=q, status=status, orphan=orphan) template = "_partials/corpus_rows.html" if request.headers.get("HX-Request") else "corpus.html" templates: Jinja2Templates = request.app.state.templates diff --git a/tests/web/test_requirements.py b/tests/web/test_requirements.py index 1cd0612..f4c003f 100644 --- a/tests/web/test_requirements.py +++ b/tests/web/test_requirements.py @@ -66,6 +66,21 @@ def test_corpus_htmx_partial(client: TestClient) -> None: assert " None: + """Approved requirements must show the approved version title, not the display-id.""" + app: Starlette = client.app # type: ignore[assignment] + ctx = app.state.ctx_factory() + draft = ctx.service.create_requirement("Approved title", "statement", actor="human:alice") + ctx.service.approve_requirement( + draft.requirement_id, + actor="human:alice", + expected_version=0, + ) + resp = client.get("/") + assert resp.status_code == 200 + assert "Approved title" in resp.text + + # --- Unit tests for filter_rows --- From 9bfca28e23be41c46ef3ac7acec7df809a11dbdf Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:32:39 +1000 Subject: [PATCH 10/29] feat(web): corpus inline expand with independent per-row targets --- src/plainweave/web/routes/requirements.py | 26 ++++++++++++++++++- .../web/templates/_partials/req_inline.html | 7 +++++ tests/web/test_requirements.py | 15 +++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/plainweave/web/templates/_partials/req_inline.html diff --git a/src/plainweave/web/routes/requirements.py b/src/plainweave/web/routes/requirements.py index f7b93ba..2580083 100644 --- a/src/plainweave/web/routes/requirements.py +++ b/src/plainweave/web/routes/requirements.py @@ -2,7 +2,7 @@ from starlette.applications import Starlette from starlette.requests import Request -from starlette.responses import Response +from starlette.responses import HTMLResponse, Response from starlette.routing import Route from starlette.templating import Jinja2Templates @@ -54,5 +54,29 @@ async def corpus(request: Request) -> Response: ) +async def req_inline(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + req_id = request.path_params["req_id"] + dossier = ctx.service.requirement_dossier(req_id) + section = dossier.requirement + statement = ( + section.active_draft.statement + if section.active_draft + else (section.current_version.statement if section.current_version else "") + ) + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "_partials/req_inline.html", + {"req_id": req_id, "statement": statement, "status": dossier.requirement.record.status}, + ) + + +async def req_inline_collapsed(request: Request) -> Response: + return HTMLResponse("") + + def register(app: Starlette) -> None: app.router.routes.append(Route("/", corpus, name="corpus")) + app.router.routes.append(Route("/req/{req_id}/inline", req_inline, name="req_inline")) + app.router.routes.append(Route("/req/{req_id}/inline/collapsed", req_inline_collapsed, name="req_inline_collapsed")) diff --git a/src/plainweave/web/templates/_partials/req_inline.html b/src/plainweave/web/templates/_partials/req_inline.html new file mode 100644 index 0000000..65c4605 --- /dev/null +++ b/src/plainweave/web/templates/_partials/req_inline.html @@ -0,0 +1,7 @@ +
+

{{ statement }}

+
+ Full dossier → + +
+
diff --git a/tests/web/test_requirements.py b/tests/web/test_requirements.py index f4c003f..97bf596 100644 --- a/tests/web/test_requirements.py +++ b/tests/web/test_requirements.py @@ -168,6 +168,21 @@ def test_filter_rows_combined() -> None: assert result[0].status == "draft" +def test_inline_expand_independent_targets(client: TestClient) -> None: + a = _mint(client, "Alpha req", "alpha statement body") + b = _mint(client, "Beta req", "beta statement body") + ra = client.get(f"/req/{a.requirement_id}/inline") + assert ra.status_code == 200 + assert "alpha statement body" in ra.text + assert f'id="req-detail-{a.requirement_id}"' not in ra.text # partial targets the existing row div, not nested + rb = client.get(f"/req/{b.requirement_id}/inline") + assert "beta statement body" in rb.text + # collapse returns empty + rc = client.get(f"/req/{a.requirement_id}/inline/collapsed") + assert rc.status_code == 200 + assert rc.text.strip() == "" + + # --- Unit tests for build_corpus_rows --- From df91c4d38392b5b352467e170cf136795f7bdb09 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:38:08 +1000 Subject: [PATCH 11/29] feat(web): requirement detail showing current vs draft side by side --- src/plainweave/web/routes/requirements.py | 13 +++++++++++ .../web/templates/requirement_detail.html | 23 +++++++++++++++++++ tests/web/test_requirements.py | 8 +++++++ 3 files changed, 44 insertions(+) create mode 100644 src/plainweave/web/templates/requirement_detail.html diff --git a/src/plainweave/web/routes/requirements.py b/src/plainweave/web/routes/requirements.py index 2580083..09afc48 100644 --- a/src/plainweave/web/routes/requirements.py +++ b/src/plainweave/web/routes/requirements.py @@ -76,7 +76,20 @@ async def req_inline_collapsed(request: Request) -> Response: return HTMLResponse("") +async def req_detail(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + req_id = request.path_params["req_id"] + dossier = ctx.service.requirement_dossier(req_id) + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "requirement_detail.html", + {"dossier": dossier, "req_id": req_id, "operator": ctx.operator, "active_page": "corpus"}, + ) + + def register(app: Starlette) -> None: app.router.routes.append(Route("/", corpus, name="corpus")) + app.router.routes.append(Route("/req/{req_id}", req_detail, name="req_detail")) app.router.routes.append(Route("/req/{req_id}/inline", req_inline, name="req_inline")) app.router.routes.append(Route("/req/{req_id}/inline/collapsed", req_inline_collapsed, name="req_inline_collapsed")) diff --git a/src/plainweave/web/templates/requirement_detail.html b/src/plainweave/web/templates/requirement_detail.html new file mode 100644 index 0000000..127f137 --- /dev/null +++ b/src/plainweave/web/templates/requirement_detail.html @@ -0,0 +1,23 @@ +{# src/plainweave/web/templates/requirement_detail.html #} +{% extends "base.html" %} +{% set active_page = "corpus" %} +{% set section = dossier.requirement %} +{% block title %}{{ section.record.id }} · Plainweave{% endblock %} +{% block main %} +

{% if section.current_version %}{{ section.current_version.title }}{% elif section.active_draft %}{{ section.active_draft.title }}{% endif %} + {{ section.record.id }} · {{ section.record.status }}

+ +{% if section.current_version %} +

Current approved — v{{ section.current_version.version }}

+

{{ section.current_version.statement }}

+{% endif %} +{% if section.active_draft %} +

Draft{% if section.current_version %} (proposed changes){% else %} (new — no approved version yet){% endif %}

+

{{ section.active_draft.statement }}

+

Edit draft

+
+ +
+
+{% endif %} +{% endblock %} diff --git a/tests/web/test_requirements.py b/tests/web/test_requirements.py index 97bf596..4722c7a 100644 --- a/tests/web/test_requirements.py +++ b/tests/web/test_requirements.py @@ -244,3 +244,11 @@ def test_build_corpus_rows_approved_uses_version_title() -> None: assert rows[0].title == "Approved Title" assert rows[0].goal_count == 1 assert rows[0].code_count == 2 + + +def test_requirement_detail_renders_statement(client: TestClient) -> None: + r = _mint(client, "Detail req", "the full detail statement") + resp = client.get(f"/req/{r.requirement_id}") + assert resp.status_code == 200 + assert "Detail req" in resp.text + assert "the full detail statement" in resp.text From 9b476fbf29b653cd2853b877d3f60a3fcb2f5445 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:44:16 +1000 Subject: [PATCH 12/29] test(web): cover approved-version render path on requirement detail Adds test_requirement_detail_approved_version_block which creates a requirement, approves it (clears active_draft), then GETs /req/{id} and asserts the "Current approved" heading and statement are present and the draft section is absent. Includes a comment explaining why the both-blocks case is unreachable via the public API (approve_requirement and supersede_requirement both null out active_draft_id; no verb opens a draft on an already-approved requirement). Co-Authored-By: Claude Sonnet 4.6 --- tests/web/test_requirements.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/web/test_requirements.py b/tests/web/test_requirements.py index 4722c7a..d6f0bea 100644 --- a/tests/web/test_requirements.py +++ b/tests/web/test_requirements.py @@ -252,3 +252,29 @@ def test_requirement_detail_renders_statement(client: TestClient) -> None: assert resp.status_code == 200 assert "Detail req" in resp.text assert "the full detail statement" in resp.text + + +def test_requirement_detail_approved_version_block(client: TestClient) -> None: + """Approved requirement: current-version block present, draft block absent.""" + app: Starlette = client.app # type: ignore[assignment] + ctx = app.state.ctx_factory() + draft = ctx.service.create_requirement( + "Approved version title", + "approved version statement", + actor="human:alice", + ) + req_id = draft.requirement_id + ctx.service.approve_requirement(req_id, actor="human:alice", expected_version=0) + resp = client.get(f"/req/{req_id}") + assert resp.status_code == 200 + # Approved block heading present (template: "Current approved — v1") + assert "Current approved" in resp.text + # Approved statement carried through + assert "approved version statement" in resp.text + # Draft block absent (class="draft" is the discriminator for that section) + # Note: the both-blocks case (approved + active draft simultaneously) is not + # reachable via the public PlainweaveService API. approve_requirement sets + # active_draft_id=null, and supersede_requirement likewise creates a new + # approved version directly without a draft step — there is no verb that opens + # a fresh draft on a requirement that already has a current_version. + assert 'class="draft"' not in resp.text From f6c36252504a2bae1e7686b19801157cbb45f860 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:52:33 +1000 Subject: [PATCH 13/29] feat(web): intent dashboard with coverage, orphans, and no-silent-clean banner --- src/plainweave/web/routes/intent.py | 31 ++++++++++++++++++++- src/plainweave/web/templates/intent.html | 17 ++++++++++++ src/plainweave/web/views.py | 6 +++++ tests/web/test_intent.py | 34 ++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 src/plainweave/web/templates/intent.html create mode 100644 tests/web/test_intent.py diff --git a/src/plainweave/web/routes/intent.py b/src/plainweave/web/routes/intent.py index 03ac990..d0f5e68 100644 --- a/src/plainweave/web/routes/intent.py +++ b/src/plainweave/web/routes/intent.py @@ -1,6 +1,35 @@ from __future__ import annotations from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Route +from starlette.templating import Jinja2Templates +from plainweave.intent_graph import IntentLevel +from plainweave.web import views -def register(app: Starlette) -> None: ... + +async def intent_dashboard(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + cov = ctx.service.intent_coverage() + orphans = { + level.value: ctx.service.intent_orphans(level) + for level in (IntentLevel.CODE, IntentLevel.REQUIREMENT, IntentLevel.GOAL) + } + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "intent.html", + { + "cov": cov, + "banner": views.coverage_banner(cov), + "orphans": orphans, + "operator": ctx.operator, + "active_page": "intent", + }, + ) + + +def register(app: Starlette) -> None: + app.router.routes.append(Route("/intent", intent_dashboard, name="intent")) diff --git a/src/plainweave/web/templates/intent.html b/src/plainweave/web/templates/intent.html new file mode 100644 index 0000000..402c0ea --- /dev/null +++ b/src/plainweave/web/templates/intent.html @@ -0,0 +1,17 @@ +{# src/plainweave/web/templates/intent.html #} +{% extends "base.html" %} +{% set active_page = "intent" %} +{% block title %}Intent · Plainweave{% endblock %} +{% block main %} +

Intent coverage

+{% if banner %}{% endif %} +

+ {% if cov.ratio is not none %}{{ "%.0f%%"|format(cov.ratio * 100) }}{% else %}—{% endif %} + {{ cov.numerator }}/{{ cov.denominator }} public surfaces answer "why does this exist?" +

+{% for level, nodes in orphans.items() %} +

Orphans — {{ level }} ({{ nodes|length }})

+
    {% for n in nodes %}
  • {{ n.node_id }}
  • {% endfor %}
+
+{% endfor %} +{% endblock %} diff --git a/src/plainweave/web/views.py b/src/plainweave/web/views.py index f120ba0..706726f 100644 --- a/src/plainweave/web/views.py +++ b/src/plainweave/web/views.py @@ -47,6 +47,12 @@ def build_corpus_rows( return rows +def coverage_banner(cov: object) -> str | None: + if getattr(cov, "denominator_complete", True) and not getattr(cov, "adapter_degraded", ()): + return None + return "Coverage denominator is incomplete — the Loomweave catalog is absent or stale. This number is partial." + + def filter_rows(rows: list[CorpusRow], *, q: str, status: str, orphan: str) -> list[CorpusRow]: out = rows if q: diff --git a/tests/web/test_intent.py b/tests/web/test_intent.py new file mode 100644 index 0000000..0b72c6b --- /dev/null +++ b/tests/web/test_intent.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from starlette.testclient import TestClient + +from plainweave.web import views +from plainweave.web.app import create_app + + +@pytest.fixture +def client(project_root: Path) -> TestClient: + return TestClient(create_app(actor="human:alice", root=project_root)) + + +def test_intent_dashboard_renders(client: TestClient) -> None: + resp = client.get("/intent") + assert resp.status_code == 200 + assert "Coverage" in resp.text + + +def test_degraded_banner_when_denominator_incomplete() -> None: + class _Cov: + denominator_complete = False + adapter_degraded = ({"reason": "loomweave catalog stale"},) + + assert views.coverage_banner(_Cov()) is not None + + class _Ok: + denominator_complete = True + adapter_degraded = () + + assert views.coverage_banner(_Ok()) is None From 9065f8679aed040e819ad19167462c88690e993a Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:58:01 +1000 Subject: [PATCH 14/29] feat(web): goals list page (with list_goals read if needed) Co-Authored-By: Claude Sonnet 4.6 --- src/plainweave/service.py | 5 +++++ src/plainweave/web/routes/goals.py | 21 ++++++++++++++++++++- src/plainweave/web/templates/goals.html | 13 +++++++++++++ tests/web/test_goals.py | 23 +++++++++++++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 src/plainweave/web/templates/goals.html create mode 100644 tests/web/test_goals.py diff --git a/src/plainweave/service.py b/src/plainweave/service.py index d56e1bd..a4f4688 100644 --- a/src/plainweave/service.py +++ b/src/plainweave/service.py @@ -1051,6 +1051,11 @@ def create_goal(self, title: str, statement: str, *, actor: str) -> IntentGoal: connection.commit() return IntentGoal(goal_id, display_id, stable_id, title, statement, "active", actor, now) + def list_goals(self) -> list[IntentGoal]: + with connect(self.db_path) as connection: + rows = connection.execute("select * from intent_goals order by display_id").fetchall() + return [self._goal_from_row(row) for row in rows] + def link_goal_to_requirement(self, goal_id: str, requirement_id: str, *, actor: str) -> IntentEdge: self._require_actor(actor) now = self._now() diff --git a/src/plainweave/web/routes/goals.py b/src/plainweave/web/routes/goals.py index 03ac990..665587a 100644 --- a/src/plainweave/web/routes/goals.py +++ b/src/plainweave/web/routes/goals.py @@ -1,6 +1,25 @@ from __future__ import annotations from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Route +from starlette.templating import Jinja2Templates +from plainweave.intent_graph import IntentLevel -def register(app: Starlette) -> None: ... + +async def goals_page(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + goals = ctx.service.list_goals() + orphan_goal_ids = {n.node_id for n in ctx.service.intent_orphans(IntentLevel.GOAL)} + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "goals.html", + {"goals": goals, "orphan_goal_ids": orphan_goal_ids, "operator": ctx.operator, "active_page": "goals"}, + ) + + +def register(app: Starlette) -> None: + app.router.routes.append(Route("/goals", goals_page, name="goals")) diff --git a/src/plainweave/web/templates/goals.html b/src/plainweave/web/templates/goals.html new file mode 100644 index 0000000..ee70594 --- /dev/null +++ b/src/plainweave/web/templates/goals.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% set active_page = "goals" %} +{% block title %}Goals · Plainweave{% endblock %} +{% block main %} +

Goals

+
    +{% for g in goals %} +
  • {{ g.title }} {{ g.id }}{% if g.goal_id in orphan_goal_ids %} — no requirements ladder here{% endif %}
  • +{% else %} +
  • No goals yet.
  • +{% endfor %} +
+{% endblock %} diff --git a/tests/web/test_goals.py b/tests/web/test_goals.py new file mode 100644 index 0000000..55df608 --- /dev/null +++ b/tests/web/test_goals.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +from plainweave.web.app import create_app + + +@pytest.fixture +def client(project_root: Path) -> TestClient: + return TestClient(create_app(actor="human:alice", root=project_root)) + + +def test_goals_page_lists_created_goal(client: TestClient) -> None: + app: Starlette = client.app # type: ignore[assignment] + ctx = app.state.ctx_factory() + ctx.service.create_goal("Be self-computable", "the north-star goal", actor="human:alice") + resp = client.get("/goals") + assert resp.status_code == 200 + assert "Be self-computable" in resp.text From 73d7319e0db082b6d66255bd9ed3100656ceef87 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:06:30 +1000 Subject: [PATCH 15/29] feat(web): create/edit requirement with conflict-preserves-text UX Co-Authored-By: Claude Sonnet 4.6 --- src/plainweave/web/routes/requirements.py | 88 ++++++++++++++++++- .../web/templates/_partials/csrf.html | 1 + .../templates/_partials/edit_conflict.html | 15 ++++ .../web/templates/requirement_form.html | 12 +++ tests/web/test_requirements.py | 58 ++++++++++++ 5 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 src/plainweave/web/templates/_partials/csrf.html create mode 100644 src/plainweave/web/templates/_partials/edit_conflict.html create mode 100644 src/plainweave/web/templates/requirement_form.html diff --git a/src/plainweave/web/routes/requirements.py b/src/plainweave/web/routes/requirements.py index 09afc48..5af421b 100644 --- a/src/plainweave/web/routes/requirements.py +++ b/src/plainweave/web/routes/requirements.py @@ -2,10 +2,11 @@ from starlette.applications import Starlette from starlette.requests import Request -from starlette.responses import HTMLResponse, Response +from starlette.responses import HTMLResponse, RedirectResponse, Response from starlette.routing import Route from starlette.templating import Jinja2Templates +from plainweave.errors import ErrorCode, PlainweaveError from plainweave.models import RequirementRecord from plainweave.service import PlainweaveService from plainweave.web import views @@ -88,8 +89,93 @@ async def req_detail(request: Request) -> Response: ) +async def req_new_get(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "requirement_form.html", + { + "req_id": None, + "title": "", + "statement": "", + "expected_draft_revision": None, + "operator": ctx.operator, + "active_page": "corpus", + }, + ) + + +async def req_new_post(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + form = await request.form() + ctx.service.create_requirement(str(form["title"]), str(form["statement"]), actor=ctx.operator.actor_id) + return RedirectResponse("/", status_code=303) + + +async def req_edit_get(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + req_id = request.path_params["req_id"] + draft = ctx.service.requirement_dossier(req_id).requirement.active_draft + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "requirement_form.html", + { + "req_id": req_id, + "title": draft.title if draft is not None else "", + "statement": draft.statement if draft is not None else "", + "expected_draft_revision": draft.draft_revision if draft is not None else None, + "operator": ctx.operator, + "active_page": "corpus", + }, + ) + + +async def req_edit_post(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + req_id = request.path_params["req_id"] + form = await request.form() + title, statement = str(form["title"]), str(form["statement"]) + expected = int(str(form.get("expected_draft_revision", "0"))) + try: + ctx.service.update_draft( + req_id, + actor=ctx.operator.actor_id, + title=title, + statement=statement, + expected_draft_revision=expected, + ) + return RedirectResponse(f"/req/{req_id}", status_code=303) + except PlainweaveError as exc: + if exc.code is not ErrorCode.CONFLICT: + raise # falls through to the global handler + # Local catch: HTMX only swaps 2xx; return 200 with both texts preserved. + draft = ctx.service.requirement_dossier(req_id).requirement.active_draft + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "_partials/edit_conflict.html", + { + "req_id": req_id, + "submitted_title": title, + "submitted_statement": statement, + "current_title": draft.title if draft is not None else "", + "current_statement": draft.statement if draft is not None else "", + "fresh_revision": draft.draft_revision if draft is not None else 0, + }, + status_code=200, + ) + + def register(app: Starlette) -> None: app.router.routes.append(Route("/", corpus, name="corpus")) + # /req/new MUST precede /req/{req_id} — Starlette matches in registration order; + # a literal "new" segment would otherwise be captured as req_id. + app.router.routes.append(Route("/req/new", req_new_get, name="req_new")) + app.router.routes.append(Route("/req/new", req_new_post, methods=["POST"])) app.router.routes.append(Route("/req/{req_id}", req_detail, name="req_detail")) app.router.routes.append(Route("/req/{req_id}/inline", req_inline, name="req_inline")) app.router.routes.append(Route("/req/{req_id}/inline/collapsed", req_inline_collapsed, name="req_inline_collapsed")) + app.router.routes.append(Route("/req/{req_id}/edit", req_edit_get, name="req_edit")) + app.router.routes.append(Route("/req/{req_id}/edit", req_edit_post, methods=["POST"])) diff --git a/src/plainweave/web/templates/_partials/csrf.html b/src/plainweave/web/templates/_partials/csrf.html new file mode 100644 index 0000000..24e4f83 --- /dev/null +++ b/src/plainweave/web/templates/_partials/csrf.html @@ -0,0 +1 @@ + diff --git a/src/plainweave/web/templates/_partials/edit_conflict.html b/src/plainweave/web/templates/_partials/edit_conflict.html new file mode 100644 index 0000000..fcb8774 --- /dev/null +++ b/src/plainweave/web/templates/_partials/edit_conflict.html @@ -0,0 +1,15 @@ +
+ +
+
+ {% include "_partials/csrf.html" %} + +

Your edits (not saved)

+ + + +
+

Current draft

{{ current_title }}

{{ current_statement }}

+ Discard mine — start from current
+
+
diff --git a/src/plainweave/web/templates/requirement_form.html b/src/plainweave/web/templates/requirement_form.html new file mode 100644 index 0000000..63eb23e --- /dev/null +++ b/src/plainweave/web/templates/requirement_form.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% set active_page = "corpus" %} +{% block main %} +

{% if req_id %}Edit draft{% else %}New requirement{% endif %}

+
+ {% include "_partials/csrf.html" %} + {% if expected_draft_revision is not none %}{% endif %} + + + +
+{% endblock %} diff --git a/tests/web/test_requirements.py b/tests/web/test_requirements.py index d6f0bea..6cb9595 100644 --- a/tests/web/test_requirements.py +++ b/tests/web/test_requirements.py @@ -278,3 +278,61 @@ def test_requirement_detail_approved_version_block(client: TestClient) -> None: # approved version directly without a draft step — there is no verb that opens # a fresh draft on a requirement that already has a current_version. assert 'class="draft"' not in resp.text + + +# --- New/Edit requirement routes --- + + +def test_new_req_form_renders(client: TestClient) -> None: + """GET /req/new must return 200 with the form — not 404 from req_detail collision.""" + resp = client.get("/req/new") + assert resp.status_code == 200 + assert 'name="title"' in resp.text + assert "New requirement" in resp.text + + +def test_create_requirement(client: TestClient) -> None: + token = client.get("/").cookies.get("pw_csrf") # ensure cookie set + resp = client.post("/req/new", data={"title": "Newborn", "statement": "fresh shell", "_csrf": token}) + assert resp.status_code in (200, 303) + assert "Newborn" in client.get("/").text + + +def test_edit_form_renders(client: TestClient) -> None: + """GET /req/{req_id}/edit must render the form pre-populated with the current draft.""" + r = _mint(client, "Editable Req", "initial body") + token = client.get("/").cookies.get("pw_csrf") + resp = client.get(f"/req/{r.requirement_id}/edit", cookies={"pw_csrf": token or ""}) + assert resp.status_code == 200 + assert "Editable Req" in resp.text + assert "initial body" in resp.text + assert "Edit draft" in resp.text + + +def test_edit_success_redirects(client: TestClient) -> None: + """POST /req/{req_id}/edit with correct revision redirects to detail page.""" + r = _mint(client, "Success Req", "original body") + token = client.get("/").cookies.get("pw_csrf") + resp = client.post( + f"/req/{r.requirement_id}/edit", + data={ + "title": "Success Req", + "statement": "updated body", + "expected_draft_revision": "1", + "_csrf": token, + }, + ) + assert resp.status_code in (200, 303) + + +def test_edit_conflict_preserves_text(client: TestClient) -> None: + r = _mint(client, "Editable", "v1 body") + token = client.get("/").cookies.get("pw_csrf") + # submit a stale revision (0 when the real draft_revision is 1) + resp = client.post( + f"/req/{r.requirement_id}/edit", + data={"title": "Editable", "statement": "MY UNSAVED EDIT", "expected_draft_revision": "0", "_csrf": token}, + ) + assert resp.status_code == 200 # NOT 409 — HTMX must be able to swap + assert "MY UNSAVED EDIT" in resp.text # operator's text echoed back + assert "v1 body" in resp.text # current draft shown alongside From 94154969581a4c703fd6e6855b8b4f0b96e97f20 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:09:50 +1000 Subject: [PATCH 16/29] test(web): strengthen test_edit_success_redirects to verify update persisted Co-Authored-By: Claude Sonnet 4.6 --- tests/web/test_requirements.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/web/test_requirements.py b/tests/web/test_requirements.py index 6cb9595..a8688a6 100644 --- a/tests/web/test_requirements.py +++ b/tests/web/test_requirements.py @@ -310,7 +310,7 @@ def test_edit_form_renders(client: TestClient) -> None: def test_edit_success_redirects(client: TestClient) -> None: - """POST /req/{req_id}/edit with correct revision redirects to detail page.""" + """POST /req/{req_id}/edit with correct revision redirects and update is persisted.""" r = _mint(client, "Success Req", "original body") token = client.get("/").cookies.get("pw_csrf") resp = client.post( @@ -323,6 +323,8 @@ def test_edit_success_redirects(client: TestClient) -> None: }, ) assert resp.status_code in (200, 303) + # Verify the update was persisted (TestClient follows redirects by default) + assert "updated body" in resp.text def test_edit_conflict_preserves_text(client: TestClient) -> None: From 4cad3598eb150cec9e37937bede24036c2d8c854 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:14:26 +1000 Subject: [PATCH 17/29] feat(web): create goals and ladder requirements to goals Co-Authored-By: Claude Sonnet 4.6 --- src/plainweave/web/routes/goals.py | 19 ++++++++++++++++++- src/plainweave/web/routes/requirements.py | 3 ++- src/plainweave/web/templates/goals.html | 6 ++++++ .../web/templates/requirement_detail.html | 16 ++++++++++++++++ tests/web/test_goals.py | 17 +++++++++++++++++ 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/plainweave/web/routes/goals.py b/src/plainweave/web/routes/goals.py index 665587a..01e928e 100644 --- a/src/plainweave/web/routes/goals.py +++ b/src/plainweave/web/routes/goals.py @@ -2,7 +2,7 @@ from starlette.applications import Starlette from starlette.requests import Request -from starlette.responses import Response +from starlette.responses import RedirectResponse, Response from starlette.routing import Route from starlette.templating import Jinja2Templates @@ -21,5 +21,22 @@ async def goals_page(request: Request) -> Response: ) +async def goals_new(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + form = await request.form() + ctx.service.create_goal(str(form["title"]), str(form["statement"]), actor=ctx.operator.actor_id) + return RedirectResponse("/goals", status_code=303) + + +async def req_ladder(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + req_id = request.path_params["req_id"] + form = await request.form() + ctx.service.link_goal_to_requirement(str(form["goal_id"]), req_id, actor=ctx.operator.actor_id) + return RedirectResponse(f"/req/{req_id}", status_code=303) + + def register(app: Starlette) -> None: app.router.routes.append(Route("/goals", goals_page, name="goals")) + app.router.routes.append(Route("/goals/new", goals_new, methods=["POST"])) + app.router.routes.append(Route("/req/{req_id}/ladder", req_ladder, methods=["POST"])) diff --git a/src/plainweave/web/routes/requirements.py b/src/plainweave/web/routes/requirements.py index 5af421b..942e24f 100644 --- a/src/plainweave/web/routes/requirements.py +++ b/src/plainweave/web/routes/requirements.py @@ -81,11 +81,12 @@ async def req_detail(request: Request) -> Response: ctx = request.app.state.ctx_factory() req_id = request.path_params["req_id"] dossier = ctx.service.requirement_dossier(req_id) + goals = ctx.service.list_goals() templates: Jinja2Templates = request.app.state.templates return templates.TemplateResponse( request, "requirement_detail.html", - {"dossier": dossier, "req_id": req_id, "operator": ctx.operator, "active_page": "corpus"}, + {"dossier": dossier, "req_id": req_id, "operator": ctx.operator, "active_page": "corpus", "goals": goals}, ) diff --git a/src/plainweave/web/templates/goals.html b/src/plainweave/web/templates/goals.html index ee70594..2fcb2f3 100644 --- a/src/plainweave/web/templates/goals.html +++ b/src/plainweave/web/templates/goals.html @@ -3,6 +3,12 @@ {% block title %}Goals · Plainweave{% endblock %} {% block main %}

Goals

+
+ {% include "_partials/csrf.html" %} + + + +
    {% for g in goals %}
  • {{ g.title }} {{ g.id }}{% if g.goal_id in orphan_goal_ids %} — no requirements ladder here{% endif %}
  • diff --git a/src/plainweave/web/templates/requirement_detail.html b/src/plainweave/web/templates/requirement_detail.html index 127f137..746ff54 100644 --- a/src/plainweave/web/templates/requirement_detail.html +++ b/src/plainweave/web/templates/requirement_detail.html @@ -20,4 +20,20 @@

    {% if section.current_version %}{{ section.current_version.title }}{% elif s {% endif %} +{% if goals %} +
    +

    Ladder to a goal

    +
    + {% include "_partials/csrf.html" %} + + +
    +
    +{% endif %} {% endblock %} diff --git a/tests/web/test_goals.py b/tests/web/test_goals.py index 55df608..4511d13 100644 --- a/tests/web/test_goals.py +++ b/tests/web/test_goals.py @@ -21,3 +21,20 @@ def test_goals_page_lists_created_goal(client: TestClient) -> None: resp = client.get("/goals") assert resp.status_code == 200 assert "Be self-computable" in resp.text + + +def test_create_goal_and_ladder(client: TestClient) -> None: + token = client.get("/goals").cookies.get("pw_csrf") + client.post("/goals/new", data={"title": "Ladder target", "statement": "g", "_csrf": token}) + app: Starlette = client.app # type: ignore[assignment] + ctx = app.state.ctx_factory() + req = ctx.service.create_requirement("Ladders up", "body", actor="human:alice") + goals = ctx.service.list_goals() + gid = goals[0].goal_id + resp = client.post(f"/req/{req.requirement_id}/ladder", data={"goal_id": gid, "_csrf": token}) + assert resp.status_code in (200, 303) + # the requirement now ladders to a goal → no longer a requirement-orphan + from plainweave.intent_graph import IntentLevel + + orphan_ids = {n.node_id for n in ctx.service.intent_orphans(IntentLevel.REQUIREMENT)} + assert req.requirement_id not in orphan_ids From 285a6f836af1bf4ab7f2cc04074800103223d126 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:27:10 +1000 Subject: [PATCH 18/29] feat(web): unified review queue (drafts + proposed links) with empty state and focus script Co-Authored-By: Claude Sonnet 4.6 --- src/plainweave/web/routes/review.py | 25 +++++++- .../web/templates/_partials/queue_empty.html | 4 ++ .../templates/_partials/queue_item_draft.html | 11 ++++ .../templates/_partials/queue_item_link.html | 22 +++++++ src/plainweave/web/templates/review.html | 28 ++++++++ src/plainweave/web/views.py | 61 ++++++++++++++++++ tests/web/test_review.py | 64 +++++++++++++++++++ 7 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 src/plainweave/web/templates/_partials/queue_empty.html create mode 100644 src/plainweave/web/templates/_partials/queue_item_draft.html create mode 100644 src/plainweave/web/templates/_partials/queue_item_link.html create mode 100644 src/plainweave/web/templates/review.html create mode 100644 tests/web/test_review.py diff --git a/src/plainweave/web/routes/review.py b/src/plainweave/web/routes/review.py index 03ac990..f096692 100644 --- a/src/plainweave/web/routes/review.py +++ b/src/plainweave/web/routes/review.py @@ -1,6 +1,29 @@ from __future__ import annotations from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Route +from starlette.templating import Jinja2Templates +from plainweave.web import views -def register(app: Starlette) -> None: ... + +async def review(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + items = views.pending_items(ctx.service) + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "review.html", + { + "items": items, + "pending_count": len(items), + "operator": ctx.operator, + "active_page": "review", + }, + ) + + +def register(app: Starlette) -> None: + app.router.routes.append(Route("/review", review, name="review")) diff --git a/src/plainweave/web/templates/_partials/queue_empty.html b/src/plainweave/web/templates/_partials/queue_empty.html new file mode 100644 index 0000000..e77764a --- /dev/null +++ b/src/plainweave/web/templates/_partials/queue_empty.html @@ -0,0 +1,4 @@ +
    +

    All caught up

    +

    No pending drafts or trace links to review.

    +
    diff --git a/src/plainweave/web/templates/_partials/queue_item_draft.html b/src/plainweave/web/templates/_partials/queue_item_draft.html new file mode 100644 index 0000000..37d43c3 --- /dev/null +++ b/src/plainweave/web/templates/_partials/queue_item_draft.html @@ -0,0 +1,11 @@ +
    +
    DRAFT +

    {{ item.title }} {{ item.display_id }}

    +

    {{ item.statement | truncate(200) }}

    +
    + View full draft → + +
    +
    diff --git a/src/plainweave/web/templates/_partials/queue_item_link.html b/src/plainweave/web/templates/_partials/queue_item_link.html new file mode 100644 index 0000000..9f07e49 --- /dev/null +++ b/src/plainweave/web/templates/_partials/queue_item_link.html @@ -0,0 +1,22 @@ + diff --git a/src/plainweave/web/templates/review.html b/src/plainweave/web/templates/review.html new file mode 100644 index 0000000..8a78b0c --- /dev/null +++ b/src/plainweave/web/templates/review.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% set active_page = "review" %} +{% block title %}Review · Plainweave{% endblock %} +{% block main %} +

    Review queue

    +
    + {% if items %} + {% for item in items %} + {% if item.kind == 'draft' %}{% include "_partials/queue_item_draft.html" %} + {% else %}{% include "_partials/queue_item_link.html" %}{% endif %} + {% endfor %} + {% else %}{% include "_partials/queue_empty.html" %}{% endif %} +
    + +{% endblock %} diff --git a/src/plainweave/web/views.py b/src/plainweave/web/views.py index 706726f..7b1b721 100644 --- a/src/plainweave/web/views.py +++ b/src/plainweave/web/views.py @@ -1,10 +1,14 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING from plainweave.intent_graph import CorpusEntry from plainweave.models import RequirementRecord +if TYPE_CHECKING: + from plainweave.service import PlainweaveService + @dataclass(frozen=True) class CorpusRow: @@ -53,6 +57,63 @@ def coverage_banner(cov: object) -> str | None: return "Coverage denominator is incomplete — the Loomweave catalog is absent or stale. This number is partial." +@dataclass(frozen=True) +class DraftItem: + kind: str # "draft" + req_id: str + display_id: str + title: str + statement: str + current_version: int + + +@dataclass(frozen=True) +class LinkItem: + kind: str # "link" + link_id: str + from_label: str + relation: str + to_label: str + proposing_actor: str + confidence: float | None + drifted: bool + + +def pending_items(service: PlainweaveService) -> list[DraftItem | LinkItem]: + """Return unified review queue: pending drafts + proposed trace links.""" + items: list[DraftItem | LinkItem] = [] + for rec in service.search_requirements(): + if rec.active_draft_id is None: + continue + d = service.requirement_dossier(rec.requirement_id).requirement.active_draft + if d is None: + continue + items.append( + DraftItem( + kind="draft", + req_id=rec.requirement_id, + display_id=rec.id, + title=d.title, + statement=d.statement, + current_version=rec.current_version, + ) + ) + for link in service.trace_for(state="proposed"): + items.append( + LinkItem( + kind="link", + link_id=link.id, + from_label=link.from_ref.id, + relation=link.relation, + to_label=link.to_ref.id, + proposing_actor=link.created_by, + confidence=link.confidence, + drifted=link.freshness != "current", + ) + ) + return items + + def filter_rows(rows: list[CorpusRow], *, q: str, status: str, orphan: str) -> list[CorpusRow]: out = rows if q: diff --git a/tests/web/test_review.py b/tests/web/test_review.py new file mode 100644 index 0000000..c271784 --- /dev/null +++ b/tests/web/test_review.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +from plainweave.models import TraceRef +from plainweave.web.app import create_app +from plainweave.web.views import LinkItem + + +@pytest.fixture +def client(project_root: Path) -> TestClient: + return TestClient(create_app(actor="human:alice", root=project_root)) + + +def test_empty_queue_state(client: TestClient) -> None: + resp = client.get("/review") + assert resp.status_code == 200 + assert "All caught up" in resp.text + + +def test_queue_shows_pending_draft_and_proposed_link(client: TestClient) -> None: + app: Starlette = client.app # type: ignore[assignment] + ctx = app.state.ctx_factory() + ctx.service.create_requirement("Pending draft", "body", actor="human:alice") + ctx.service.propose_trace_link( + TraceRef("test_selector", "tests/test_auth.py::test_expired"), + "provides_evidence_for", + TraceRef("verification_method", "VERM-0001"), + actor="agent:claude", + confidence=0.8, + ) + resp = client.get("/review") + assert resp.status_code == 200 + assert "Pending draft" in resp.text # the draft card + assert "DRAFT" in resp.text and "LINK" in resp.text + assert "agent:claude" in resp.text # proposing agent shown + + +def test_drift_card_branch_renders(project_root: Path) -> None: + """Unit test: LinkItem(drifted=True) renders CODE DRIFTED + aria-describedby. + + The drift branch in queue_item_link.html is unreachable from real proposed-queue + data (proposed links always have freshness == 'current'). This direct template + render ensures the branch is exercised without fabricating service state. + """ + app = create_app(actor="human:alice", root=project_root) + drifted_item = LinkItem( + kind="link", + link_id="LINK-9999", + from_label="tests/test_auth.py::test_expired", + relation="provides_evidence_for", + to_label="VERM-0001", + proposing_actor="agent:claude", + confidence=0.9, + drifted=True, + ) + templates = app.state.templates + rendered = templates.get_template("_partials/queue_item_link.html").render({"item": drifted_item}) + assert "CODE DRIFTED" in rendered + assert "aria-describedby" in rendered From ee0214ef0ab56a4197d6c39193bae7fa5849bbf2 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:40:31 +1000 Subject: [PATCH 19/29] feat(web): two-step draft approval with OOB status/badge/empty-state result Co-Authored-By: Claude Sonnet 4.6 --- src/plainweave/web/routes/review.py | 101 ++++++++++++++++++ .../_partials/draft_approve_confirm.html | 10 ++ .../_partials/queue_action_result.html | 10 ++ tests/web/test_review.py | 51 +++++++++ 4 files changed, 172 insertions(+) create mode 100644 src/plainweave/web/templates/_partials/draft_approve_confirm.html create mode 100644 src/plainweave/web/templates/_partials/queue_action_result.html diff --git a/src/plainweave/web/routes/review.py b/src/plainweave/web/routes/review.py index f096692..6c68202 100644 --- a/src/plainweave/web/routes/review.py +++ b/src/plainweave/web/routes/review.py @@ -1,13 +1,37 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import Response from starlette.routing import Route from starlette.templating import Jinja2Templates +from plainweave.errors import ErrorCode, PlainweaveError +from plainweave.models import RequirementDraft, RequirementRecord from plainweave.web import views +if TYPE_CHECKING: + from plainweave.service import PlainweaveService + + +def _pending_count(service: PlainweaveService) -> int: + return len(views.pending_items(service)) + + +def _draft_ctx(service: PlainweaveService, req_id: str) -> tuple[RequirementRecord, RequirementDraft]: + rec = service.get_requirement(req_id) + draft = service.requirement_dossier(req_id).requirement.active_draft + if draft is None: + raise PlainweaveError( + ErrorCode.POLICY_REQUIRED, + f"requirement {req_id!r} has no active draft", + recoverable=False, + hint="ensure the requirement has an active draft before approving", + ) + return rec, draft + async def review(request: Request) -> Response: ctx = request.app.state.ctx_factory() @@ -25,5 +49,82 @@ async def review(request: Request) -> Response: ) +async def approve_confirm(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + req_id: str = request.path_params["req_id"] + rec, draft = _draft_ctx(ctx.service, req_id) + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "_partials/draft_approve_confirm.html", + { + "req_id": req_id, + "title": draft.title, + "current_version": rec.current_version, + "next_version": rec.current_version + 1, + "error": None, + }, + ) + + +async def approve_post(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + req_id: str = request.path_params["req_id"] + form = await request.form() + expected = int(str(form["expected_version"])) + rec, draft = _draft_ctx(ctx.service, req_id) + templates: Jinja2Templates = request.app.state.templates + try: + ctx.service.approve_requirement(req_id, actor=ctx.operator.actor_id, expected_version=expected) + except PlainweaveError as exc: + if exc.code is not ErrorCode.CONFLICT: + raise + return templates.TemplateResponse( + request, + "_partials/draft_approve_confirm.html", + { + "req_id": req_id, + "title": draft.title, + "current_version": rec.current_version, + "next_version": rec.current_version + 1, + "error": "Draft changed since you loaded this. Reopen to see the latest.", + }, + status_code=200, + ) + remaining = _pending_count(ctx.service) + return templates.TemplateResponse( + request, + "_partials/queue_action_result.html", + { + "action_label": "Approved", + "item_desc": draft.title, + "remaining_count": remaining, + }, + ) + + +async def draft_card(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + req_id: str = request.path_params["req_id"] + rec, draft = _draft_ctx(ctx.service, req_id) + item = views.DraftItem( + kind="draft", + req_id=req_id, + display_id=rec.id, + title=draft.title, + statement=draft.statement, + current_version=rec.current_version, + ) + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "_partials/queue_item_draft.html", + {"item": item}, + ) + + def register(app: Starlette) -> None: app.router.routes.append(Route("/review", review, name="review")) + app.router.routes.append(Route("/req/{req_id}/approve-confirm", approve_confirm)) + app.router.routes.append(Route("/req/{req_id}/approve", approve_post, methods=["POST"])) + app.router.routes.append(Route("/req/{req_id}/draft-card", draft_card)) diff --git a/src/plainweave/web/templates/_partials/draft_approve_confirm.html b/src/plainweave/web/templates/_partials/draft_approve_confirm.html new file mode 100644 index 0000000..cbd5609 --- /dev/null +++ b/src/plainweave/web/templates/_partials/draft_approve_confirm.html @@ -0,0 +1,10 @@ +
    + {% if error %}{% endif %} +

    Approve {{ title }} as version {{ next_version }}? This cannot be undone — there is no un-approve.

    +
    + {% include "_partials/csrf.html" %} + + + +
    +
    diff --git a/src/plainweave/web/templates/_partials/queue_action_result.html b/src/plainweave/web/templates/_partials/queue_action_result.html new file mode 100644 index 0000000..e344036 --- /dev/null +++ b/src/plainweave/web/templates/_partials/queue_action_result.html @@ -0,0 +1,10 @@ +{# Primary target (the acted card) is replaced by NOTHING via outerHTML → card removed. #} +
    + {{ action_label }}: {{ item_desc }}. + {% if remaining_count > 0 %}{{ remaining_count }} item{{ 's' if remaining_count != 1 }} remaining in queue. + {% else %}Queue is now empty.{% endif %} +
    +{% if remaining_count > 0 %}{{ remaining_count }}{% endif %} +{% if remaining_count == 0 %} +
    {% include "_partials/queue_empty.html" %}
    +{% endif %} diff --git a/tests/web/test_review.py b/tests/web/test_review.py index c271784..e879624 100644 --- a/tests/web/test_review.py +++ b/tests/web/test_review.py @@ -40,6 +40,57 @@ def test_queue_shows_pending_draft_and_proposed_link(client: TestClient) -> None assert "agent:claude" in resp.text # proposing agent shown +def test_approve_two_step(client: TestClient) -> None: + app: Starlette = client.app # type: ignore[assignment] + ctx = app.state.ctx_factory() + req = ctx.service.create_requirement("To approve", "body", actor="human:alice") + confirm = client.get(f"/req/{req.requirement_id}/approve-confirm") + assert confirm.status_code == 200 + assert "version 1" in confirm.text.lower() or "approves version 1" in confirm.text.lower() + # Cookie is set on the first request; subsequent responses won't repeat Set-Cookie. + # Use client.cookies (the jar) rather than a specific response's cookies. + client.get("/review") + token = client.cookies.get("pw_csrf") + done = client.post( + f"/req/{req.requirement_id}/approve", + data={"expected_version": "0", "_csrf": token}, + ) + assert done.status_code == 200 + assert 'hx-swap-oob="innerHTML:#sr-status"' in done.text # announces outcome + # the requirement is now approved + assert ctx.service.get_requirement(req.requirement_id).status == "approved" + + +def test_approve_conflict_returns_confirm_with_error(client: TestClient) -> None: + """Stale expected_version → 200 + confirm partial with error banner.""" + app: Starlette = client.app # type: ignore[assignment] + ctx = app.state.ctx_factory() + req = ctx.service.create_requirement("Conflict req", "body", actor="human:alice") + # Ensure CSRF cookie is set in the jar before posting. + client.get("/review") + token = client.cookies.get("pw_csrf") + # Send wrong expected_version (e.g. 999) → CONFLICT from service + done = client.post( + f"/req/{req.requirement_id}/approve", + data={"expected_version": "999", "_csrf": token}, + ) + assert done.status_code == 200 + # Should re-render confirm partial with error, not OOB result + assert 'hx-swap-oob="innerHTML:#sr-status"' not in done.text + assert "queue-item" in done.text # confirm card was rendered + + +def test_draft_card_restore(client: TestClient) -> None: + """GET /req/{id}/draft-card renders the original queue card (Cancel restore).""" + app: Starlette = client.app # type: ignore[assignment] + ctx = app.state.ctx_factory() + req = ctx.service.create_requirement("Restore me", "body", actor="human:alice") + resp = client.get(f"/req/{req.requirement_id}/draft-card") + assert resp.status_code == 200 + assert "Restore me" in resp.text + assert "queue-item" in resp.text + + def test_drift_card_branch_renders(project_root: Path) -> None: """Unit test: LinkItem(drifted=True) renders CODE DRIFTED + aria-describedby. From e6a404d3af80130366ab2827d493d7183888e264 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:52:13 +1000 Subject: [PATCH 20/29] feat(web): accept/reject trace links with required-reason two-step and OOB result Co-Authored-By: Claude Sonnet 4.6 --- src/plainweave/web/routes/review.py | 95 +++++++++++++++++++ .../templates/_partials/link_reject_form.html | 10 ++ tests/web/test_review.py | 38 +++++++- 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 src/plainweave/web/templates/_partials/link_reject_form.html diff --git a/src/plainweave/web/routes/review.py b/src/plainweave/web/routes/review.py index 6c68202..3765611 100644 --- a/src/plainweave/web/routes/review.py +++ b/src/plainweave/web/routes/review.py @@ -123,8 +123,103 @@ async def draft_card(request: Request) -> Response: ) +def _link_item(service: PlainweaveService, link_id: str) -> views.LinkItem: + for link in service.trace_for(state="proposed"): + if link.id == link_id: + return views.LinkItem( + "link", + link.id, + link.from_ref.id, + link.relation, + link.to_ref.id, + link.created_by, + link.confidence, + link.freshness != "current", + ) + raise PlainweaveError( + ErrorCode.NOT_FOUND, + f"proposed link {link_id!r} not found", + recoverable=False, + hint="It may have already been accepted or rejected.", + ) + + +async def reject_form(request: Request) -> Response: + link_id: str = request.path_params["link_id"] + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "_partials/link_reject_form.html", + {"link_id": link_id, "submitted_reason": "", "error": None}, + ) + + +async def reject_post(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + link_id: str = request.path_params["link_id"] + form = await request.form() + reason = str(form.get("reason", "")).strip() + templates: Jinja2Templates = request.app.state.templates + if not reason: + return templates.TemplateResponse( + request, + "_partials/link_reject_form.html", + { + "link_id": link_id, + "submitted_reason": "", + "error": "Reason is required — explain why this link should be rejected.", + }, + status_code=200, + ) + item = _link_item(ctx.service, link_id) + ctx.service.reject_trace_link(link_id, actor=ctx.operator.actor_id, reason=reason) + remaining = _pending_count(ctx.service) + return templates.TemplateResponse( + request, + "_partials/queue_action_result.html", + { + "action_label": "Rejected", + "item_desc": f"{item.from_label} {item.relation} {item.to_label}", + "remaining_count": remaining, + }, + ) + + +async def accept_post(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + link_id: str = request.path_params["link_id"] + item = _link_item(ctx.service, link_id) + ctx.service.accept_trace_link(link_id, actor=ctx.operator.actor_id) + remaining = _pending_count(ctx.service) + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "_partials/queue_action_result.html", + { + "action_label": "Accepted", + "item_desc": f"{item.from_label} {item.relation} {item.to_label}", + "remaining_count": remaining, + }, + ) + + +async def link_card(request: Request) -> Response: + ctx = request.app.state.ctx_factory() + item = _link_item(ctx.service, request.path_params["link_id"]) + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "_partials/queue_item_link.html", + {"item": item}, + ) + + def register(app: Starlette) -> None: app.router.routes.append(Route("/review", review, name="review")) app.router.routes.append(Route("/req/{req_id}/approve-confirm", approve_confirm)) app.router.routes.append(Route("/req/{req_id}/approve", approve_post, methods=["POST"])) app.router.routes.append(Route("/req/{req_id}/draft-card", draft_card)) + app.router.routes.append(Route("/trace/{link_id}/accept", accept_post, methods=["POST"])) + app.router.routes.append(Route("/trace/{link_id}/reject-form", reject_form)) + app.router.routes.append(Route("/trace/{link_id}/reject", reject_post, methods=["POST"])) + app.router.routes.append(Route("/trace/{link_id}/card", link_card)) diff --git a/src/plainweave/web/templates/_partials/link_reject_form.html b/src/plainweave/web/templates/_partials/link_reject_form.html new file mode 100644 index 0000000..ed98b85 --- /dev/null +++ b/src/plainweave/web/templates/_partials/link_reject_form.html @@ -0,0 +1,10 @@ + diff --git a/tests/web/test_review.py b/tests/web/test_review.py index e879624..4a039b8 100644 --- a/tests/web/test_review.py +++ b/tests/web/test_review.py @@ -6,8 +6,9 @@ from starlette.applications import Starlette from starlette.testclient import TestClient -from plainweave.models import TraceRef +from plainweave.models import TraceLink, TraceRef from plainweave.web.app import create_app +from plainweave.web.context import RequestContext from plainweave.web.views import LinkItem @@ -91,6 +92,41 @@ def test_draft_card_restore(client: TestClient) -> None: assert "queue-item" in resp.text +def _propose(ctx: RequestContext) -> TraceLink: + return ctx.service.propose_trace_link( + TraceRef("test_selector", "tests/test_auth.py::test_expired"), + "provides_evidence_for", + TraceRef("verification_method", "VERM-0001"), + actor="agent:claude", + confidence=0.7, + ) + + +def test_reject_requires_reason(client: TestClient) -> None: + app: Starlette = client.app # type: ignore[assignment] + ctx: RequestContext = app.state.ctx_factory() + link = _propose(ctx) + client.get("/review") + token = client.cookies.get("pw_csrf") + # empty reason → 200 with inline error, link NOT rejected + resp = client.post(f"/trace/{link.id}/reject", data={"reason": "", "_csrf": token}) + assert resp.status_code == 200 + assert "reason is required" in resp.text.lower() + assert ctx.service.trace_for(state="proposed") # still proposed + + +def test_accept_link(client: TestClient) -> None: + app: Starlette = client.app # type: ignore[assignment] + ctx: RequestContext = app.state.ctx_factory() + link = _propose(ctx) + client.get("/review") + token = client.cookies.get("pw_csrf") + resp = client.post(f"/trace/{link.id}/accept", data={"_csrf": token}) + assert resp.status_code == 200 + assert 'hx-swap-oob="innerHTML:#sr-status"' in resp.text + assert not ctx.service.trace_for(state="proposed") # no longer pending + + def test_drift_card_branch_renders(project_root: Path) -> None: """Unit test: LinkItem(drifted=True) renders CODE DRIFTED + aria-describedby. From 8427777c420181dd217f3218dde1c88c5a1db7c1 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:54:53 +1000 Subject: [PATCH 21/29] test(web): add reject happy-path and link-card-restore tests for task-13 coverage Co-Authored-By: Claude Sonnet 4.6 --- tests/web/test_review.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/web/test_review.py b/tests/web/test_review.py index 4a039b8..520f0c7 100644 --- a/tests/web/test_review.py +++ b/tests/web/test_review.py @@ -127,6 +127,30 @@ def test_accept_link(client: TestClient) -> None: assert not ctx.service.trace_for(state="proposed") # no longer pending +def test_reject_link(client: TestClient) -> None: + app: Starlette = client.app # type: ignore[assignment] + ctx: RequestContext = app.state.ctx_factory() + link = _propose(ctx) + client.get("/review") + token = client.cookies.get("pw_csrf") + resp = client.post(f"/trace/{link.id}/reject", data={"reason": "stale evidence", "_csrf": token}) + assert resp.status_code == 200 + assert 'hx-swap-oob="innerHTML:#sr-status"' in resp.text + assert "Rejected" in resp.text + assert not ctx.service.trace_for(state="proposed") # actually rejected + + +def test_link_card_restore(client: TestClient) -> None: + """GET /trace/{id}/card renders the original queue card (Cancel restore).""" + app: Starlette = client.app # type: ignore[assignment] + ctx: RequestContext = app.state.ctx_factory() + link = _propose(ctx) + resp = client.get(f"/trace/{link.id}/card") + assert resp.status_code == 200 + assert "queue-item" in resp.text + assert "VERM-0001" in resp.text + + def test_drift_card_branch_renders(project_root: Path) -> None: """Unit test: LinkItem(drifted=True) renders CODE DRIFTED + aria-describedby. From 71ba2340d08eebb6b0fee3375164359bf59f7563 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:02:12 +1000 Subject: [PATCH 22/29] feat(web): extra confirm step for accepting drifted trace links Co-Authored-By: Claude Sonnet 4.6 --- src/plainweave/web/routes/review.py | 11 +++++++++++ .../_partials/link_accept_drifted_confirm.html | 10 ++++++++++ tests/web/test_review.py | 11 +++++++++++ 3 files changed, 32 insertions(+) create mode 100644 src/plainweave/web/templates/_partials/link_accept_drifted_confirm.html diff --git a/src/plainweave/web/routes/review.py b/src/plainweave/web/routes/review.py index 3765611..8bab0ff 100644 --- a/src/plainweave/web/routes/review.py +++ b/src/plainweave/web/routes/review.py @@ -214,6 +214,16 @@ async def link_card(request: Request) -> Response: ) +async def accept_drifted_confirm(request: Request) -> Response: + link_id: str = request.path_params["link_id"] + templates: Jinja2Templates = request.app.state.templates + return templates.TemplateResponse( + request, + "_partials/link_accept_drifted_confirm.html", + {"link_id": link_id}, + ) + + def register(app: Starlette) -> None: app.router.routes.append(Route("/review", review, name="review")) app.router.routes.append(Route("/req/{req_id}/approve-confirm", approve_confirm)) @@ -223,3 +233,4 @@ def register(app: Starlette) -> None: app.router.routes.append(Route("/trace/{link_id}/reject-form", reject_form)) app.router.routes.append(Route("/trace/{link_id}/reject", reject_post, methods=["POST"])) app.router.routes.append(Route("/trace/{link_id}/card", link_card)) + app.router.routes.append(Route("/trace/{link_id}/accept-drifted-confirm", accept_drifted_confirm)) diff --git a/src/plainweave/web/templates/_partials/link_accept_drifted_confirm.html b/src/plainweave/web/templates/_partials/link_accept_drifted_confirm.html new file mode 100644 index 0000000..bec8ea0 --- /dev/null +++ b/src/plainweave/web/templates/_partials/link_accept_drifted_confirm.html @@ -0,0 +1,10 @@ + diff --git a/tests/web/test_review.py b/tests/web/test_review.py index 520f0c7..8a2bfc0 100644 --- a/tests/web/test_review.py +++ b/tests/web/test_review.py @@ -151,6 +151,17 @@ def test_link_card_restore(client: TestClient) -> None: assert "VERM-0001" in resp.text +def test_drifted_link_renders_warning_and_requires_extra_confirm(client: TestClient) -> None: + """GET /trace/{lid}/accept-drifted-confirm returns 200 with drift warning and hidden field.""" + app: Starlette = client.app # type: ignore[assignment] + ctx: RequestContext = app.state.ctx_factory() + link = _propose(ctx) + confirm = client.get(f"/trace/{link.id}/accept-drifted-confirm") + assert confirm.status_code == 200 + assert "drifted" in confirm.text.lower() + assert 'name="drift_acknowledged"' in confirm.text + + def test_drift_card_branch_renders(project_root: Path) -> None: """Unit test: LinkItem(drifted=True) renders CODE DRIFTED + aria-describedby. From 4cb22ac827881839e4623ae285301022b3cbacb8 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:08:00 +1000 Subject: [PATCH 23/29] test(web): assert all five drift-confirm elements (CSRF, targets, real warning text) The previous assertion `"drifted" in confirm.text.lower()` matched only the CSS class `queue-item--drifted`. Strengthen to verify the real badge text ("CODE DRIFTED"), the role=alert paragraph phrase, the CSRF hidden input, the hx-post accept target, and the hx-get card-restore Cancel target. Co-Authored-By: Claude Sonnet 4.6 --- tests/web/test_review.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/web/test_review.py b/tests/web/test_review.py index 8a2bfc0..f05056e 100644 --- a/tests/web/test_review.py +++ b/tests/web/test_review.py @@ -152,14 +152,23 @@ def test_link_card_restore(client: TestClient) -> None: def test_drifted_link_renders_warning_and_requires_extra_confirm(client: TestClient) -> None: - """GET /trace/{lid}/accept-drifted-confirm returns 200 with drift warning and hidden field.""" + """GET /trace/{lid}/accept-drifted-confirm returns 200 with all five required confirm elements.""" app: Starlette = client.app # type: ignore[assignment] ctx: RequestContext = app.state.ctx_factory() link = _propose(ctx) confirm = client.get(f"/trace/{link.id}/accept-drifted-confirm") assert confirm.status_code == 200 - assert "drifted" in confirm.text.lower() + # 1. Real drift warning text — badge and alert paragraph (not just the CSS class) + assert "CODE DRIFTED" in confirm.text + assert "the code changed since it was proposed" in confirm.text + # 2. Hidden acknowledgement field assert 'name="drift_acknowledged"' in confirm.text + # 3. Accept POST target (hx-post ends in /accept) + assert f'hx-post="/trace/{link.id}/accept"' in confirm.text + # 4. Cancel / card-restore target + assert f'hx-get="/trace/{link.id}/card"' in confirm.text + # 5. CSRF hidden input + assert 'name="_csrf"' in confirm.text def test_drift_card_branch_renders(project_root: Path) -> None: From 150243c643efebe659422d56ed96f4ec061ce786 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:13:13 +1000 Subject: [PATCH 24/29] test(web): CSRF + missing-extra guarantees; docs: web UI quickstart Co-Authored-By: Claude Sonnet 4.6 --- README.md | 10 ++++++++++ tests/web/test_security.py | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 tests/web/test_security.py diff --git a/README.md b/README.md index 3ed3799..de9ff08 100644 --- a/README.md +++ b/README.md @@ -135,3 +135,13 @@ foundation to reshape, not the reframed feature set. See the MODULE MAP for the current → target audit. The code-up read primitives (`orphans`/`trace`/`corpus`), the SEI-keyed ADR-029 bindings, and the authoring-time write path are **stubbed with backlog markers**, not yet implemented. + +### Web UI (optional) + +Install the extra and launch the operator console: + + pip install 'plainweave[web]' + plainweave web --actor human: + +Browse the corpus, author requirements, and ratify agent-proposed drafts and +trace links. Local-first, single-operator; advisory only (no release verdicts). diff --git a/tests/web/test_security.py b/tests/web/test_security.py new file mode 100644 index 0000000..903d32a --- /dev/null +++ b/tests/web/test_security.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from starlette.testclient import TestClient + +from plainweave.web import server +from plainweave.web.app import create_app + + +def test_forged_post_without_csrf_rejected(project_root: Path) -> None: + """A POST with a bogus CSRF token (no matching cookie) must return 403.""" + fresh = TestClient(create_app(actor="human:alice", root=project_root)) + resp = fresh.post("/req/new", data={"title": "x", "statement": "y", "_csrf": "bogus"}) + assert resp.status_code == 403 + + +def test_missing_extra_prints_hint(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + """run_web must print the installation hint and return 1 when starlette is absent.""" + + def boom(**_kwargs: object) -> None: + raise ModuleNotFoundError("starlette") + + monkeypatch.setattr(server, "_serve", boom) + assert server.run_web(host="127.0.0.1", port=1, actor=None, open_browser=False) == 1 + assert "plainweave[web]" in capsys.readouterr().out From 7967ac2db85582d9b83e1185e69b20eb74f22268 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:20:50 +1000 Subject: [PATCH 25/29] test(web): structural accessibility contracts; record manual AT gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests/web/test_a11y_contracts.py locking the §4.1/§12 a11y contracts: SR status live region + skip-link in base.html, visible