fix(orchestrator): hub gate view reads recorded parents through gates.opened
W5 moved recorded parents into a journal that gates.opened() loads into a TEMP table; the W3 hub opened gates.db raw, so parents_of() raised and the gate view stayed empty. opened() and journal() take the kept directory explicitly (CLI: watch.KEPT, hub: its /kept mount), opened() closes the connection when setup raises, and the W3 tests use opened() plus a test for a parent from the kept journal. 🎯 Fix: 95 % 💥 Break: 3 % 💸 Cost: 100 % ⏱️ Time: 100 % 🛡️ Security: 100 % 🏆 Score: 92 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c4b67bb639
commit
c8b2e586de
4 files changed
+51
-27
No files matched your search
@@ -7,7 +7,6 @@ import time
|
|||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from .errors import ToolError
|
from .errors import ToolError
|
||||||
from .fields import decode, expect
|
from .fields import decode, expect
|
||||||
@@ -31,9 +30,10 @@ def shown_command(argv: list[str]) -> str:
|
|||||||
|
|
||||||
def gates_module(script: Path) -> ModuleType | None:
|
def gates_module(script: Path) -> ModuleType | None:
|
||||||
"""Returns:
|
"""Returns:
|
||||||
The installed gates.py as a module, for its resolve() and
|
The installed gates.py as a module, for its opened(), resolve() and
|
||||||
parents_of(), which read only the database; loaded again after an
|
parents_of(), which only read the database and the parent journals;
|
||||||
install changed it, None when it is missing or does not load.
|
loaded again after an install changed it, None when it is missing or
|
||||||
|
does not load.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
changed = script.stat().st_mtime_ns
|
changed = script.stat().st_mtime_ns
|
||||||
@@ -71,13 +71,14 @@ def read_gates(gates: ModuleType | None, db: Path, scan: dict) -> dict:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{repo: {branch: {parents, rules, resolved}}} as `gates.py show --json`
|
{repo: {branch: {parents, rules, resolved}}} as `gates.py show --json`
|
||||||
prints it, read with the database opened read-only and without git;
|
prints it, read through gates.opened() with the database read-only,
|
||||||
{} without the script or a readable database.
|
the kept parent journal from the same mount and without git; {}
|
||||||
|
without the script or a readable database.
|
||||||
"""
|
"""
|
||||||
if gates is None or not db.is_file():
|
if gates is None or not db.is_file():
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
with closing(sqlite3.connect(f"file:{quote(str(db))}?mode=ro", uri=True)) as connection:
|
with closing(gates.opened(db, False, db.parent)) as connection:
|
||||||
return {repo["path"]: {entry["name"]: branch_gates(gates, connection, repo["path"], entry["name"]) for entry in repo["branches"]} for repo in scan["repos"]}
|
return {repo["path"]: {entry["name"]: branch_gates(gates, connection, repo["path"], entry["name"]) for entry in repo["branches"]} for repo in scan["repos"]}
|
||||||
except sqlite3.Error:
|
except sqlite3.Error:
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -169,9 +169,9 @@ def current_branch(worktree: str) -> str | None:
|
|||||||
return named.stdout.strip() if named.returncode == 0 else None
|
return named.stdout.strip() if named.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
def journal(origin: Path, kept: Path) -> list[dict]:
|
def journal(origin: Path, kept_dir: Path, kept: Path) -> list[dict]:
|
||||||
merged: dict[str, dict] = {}
|
merged: dict[str, dict] = {}
|
||||||
for base, path in ((watch.KEPT, kept), (watch.ROOT, origin)):
|
for base, path in ((kept_dir, kept), (watch.ROOT, origin)):
|
||||||
if base == watch.ROOT and watch.guard_root(hook=True) is not None:
|
if base == watch.ROOT and watch.guard_root(hook=True) is not None:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -233,11 +233,21 @@ def receipted(repo: str, tree: str, level: str) -> bool:
|
|||||||
cutoff = time.time() - RECEIPT_SECONDS
|
cutoff = time.time() - RECEIPT_SECONDS
|
||||||
return any(
|
return any(
|
||||||
row.get("repo") == repo and row.get("tree") == tree and fresh(row, cutoff) and meets(row, level)
|
row.get("repo") == repo and row.get("tree") == tree and fresh(row, cutoff) and meets(row, level)
|
||||||
for row in journal(watch.RECEIPTS, watch.KEPT_RECEIPTS)
|
for row in journal(watch.RECEIPTS, watch.KEPT, watch.KEPT_RECEIPTS)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def opened(path: Path, write: bool) -> sqlite3.Connection:
|
def opened(path: Path, write: bool, kept_dir: Path) -> sqlite3.Connection:
|
||||||
|
"""Args:
|
||||||
|
path: gates.db; read-only unless write, in memory when it is missing.
|
||||||
|
write: open it for rule and parent changes.
|
||||||
|
kept_dir: the directory holding the kept gate-parents.jsonl: watch.KEPT
|
||||||
|
on the host, the read-only mount of it in the hub.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The connection with the TEMP table recorded filled from the parent
|
||||||
|
journals, which parents_of(), resolve() and show() read.
|
||||||
|
"""
|
||||||
if write:
|
if write:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
db = sqlite3.connect(path)
|
db = sqlite3.connect(path)
|
||||||
@@ -245,14 +255,18 @@ def opened(path: Path, write: bool) -> sqlite3.Connection:
|
|||||||
db = sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True)
|
db = sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True)
|
||||||
else:
|
else:
|
||||||
db = sqlite3.connect(":memory:")
|
db = sqlite3.connect(":memory:")
|
||||||
if write or not path.exists():
|
try:
|
||||||
db.executescript(SCHEMA)
|
if write or not path.exists():
|
||||||
db.execute("CREATE TEMP TABLE recorded (repo TEXT, branch TEXT, parent TEXT)")
|
db.executescript(SCHEMA)
|
||||||
db.executemany("INSERT INTO recorded VALUES (?, ?, ?)", [
|
db.execute("CREATE TEMP TABLE recorded (repo TEXT, branch TEXT, parent TEXT)")
|
||||||
(row["repo"], row["branch"], row["parent"])
|
db.executemany("INSERT INTO recorded VALUES (?, ?, ?)", [
|
||||||
for row in journal(watch.PARENTS, watch.KEPT_PARENTS)
|
(row["repo"], row["branch"], row["parent"])
|
||||||
if all(isinstance(row.get(key), str) for key in ("repo", "branch", "parent")) and row["branch"] != row["parent"]
|
for row in journal(watch.PARENTS, kept_dir, kept_dir / watch.KEPT_PARENTS.name)
|
||||||
])
|
if all(isinstance(row.get(key), str) for key in ("repo", "branch", "parent")) and row["branch"] != row["parent"]
|
||||||
|
])
|
||||||
|
except BaseException:
|
||||||
|
db.close()
|
||||||
|
raise
|
||||||
return db
|
return db
|
||||||
|
|
||||||
|
|
||||||
@@ -517,7 +531,7 @@ def run_hook(name: str, argv: list[str], db_path: Path) -> int:
|
|||||||
record_after_checkout(here)
|
record_after_checkout(here)
|
||||||
return 0
|
return 0
|
||||||
targets = hook_targets(name, here)
|
targets = hook_targets(name, here)
|
||||||
db = opened(db_path, False)
|
db = opened(db_path, False, watch.KEPT)
|
||||||
refused = [text for branch, tree, action in targets if (text := refusal(db, here, branch, tree, action))]
|
refused = [text for branch, tree, action in targets if (text := refusal(db, here, branch, tree, action))]
|
||||||
for text in refused:
|
for text in refused:
|
||||||
print(text, file=sys.stderr)
|
print(text, file=sys.stderr)
|
||||||
@@ -631,7 +645,7 @@ def main() -> int:
|
|||||||
verb = getattr(args, "verb", None)
|
verb = getattr(args, "verb", None)
|
||||||
if args.command in ("tree", "quick", "hooks", "hook", "receipt") or verb == "record":
|
if args.command in ("tree", "quick", "hooks", "hook", "receipt") or verb == "record":
|
||||||
return journal_command(args)
|
return journal_command(args)
|
||||||
db = opened(args.db, args.command in ("rule", "parents"))
|
db = opened(args.db, args.command in ("rule", "parents"), watch.KEPT)
|
||||||
repo = root(args.repo) if getattr(args, "repo", None) else None
|
repo = root(args.repo) if getattr(args, "repo", None) else None
|
||||||
if args.command == "resolve":
|
if args.command == "resolve":
|
||||||
level, source = resolve(db, repo, args.branch, args.action)
|
level, source = resolve(db, repo, args.branch, args.action)
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class TestWorkbenchArtifacts(HubCase):
|
|||||||
next(item for item in data["repos"][0]["items"] if item["branch"] == "feature/kept")["skip"] = None
|
next(item for item in data["repos"][0]["items"] if item["branch"] == "feature/kept")["skip"] = None
|
||||||
self.tree_file.write_text(json.dumps(data), encoding="utf-8")
|
self.tree_file.write_text(json.dumps(data), encoding="utf-8")
|
||||||
self.repo = f"{root}/repos/github.com/me/demo"
|
self.repo = f"{root}/repos/github.com/me/demo"
|
||||||
db = gates.connect(self.kept / "gates.db")
|
db = gates.opened(self.kept / "gates.db", True, self.kept)
|
||||||
db.executemany("INSERT INTO rules VALUES (?, ?, ?, ?, ?)", [(self.repo, "main", "commit", "test", 0.0), (self.repo, "feature/done", "pr", "e2e", 0.0)])
|
db.executemany("INSERT INTO rules VALUES (?, ?, ?, ?, ?)", [(self.repo, "main", "commit", "test", 0.0), (self.repo, "feature/done", "pr", "e2e", 0.0)])
|
||||||
db.executemany("INSERT INTO parents VALUES (?, ?, ?, ?)", [(self.repo, "feature", "main", "recorded"), (self.repo, "feature/done", "feature", "derived"), (self.repo, "feature/done", "feature/kept", "operator"), (self.repo, "feature/kept", "main", "derived")])
|
db.executemany("INSERT INTO parents VALUES (?, ?, ?, ?)", [(self.repo, "feature", "main", "recorded"), (self.repo, "feature/done", "feature", "derived"), (self.repo, "feature/done", "feature/kept", "operator"), (self.repo, "feature/kept", "main", "derived")])
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
+14
-5
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -131,7 +130,7 @@ class TestRunnerGates(HubCase):
|
|||||||
self.queue([rule("commit", "quality-high", branch="main", repo=repo), parent("main", branch="feature/x", repo=repo)])
|
self.queue([rule("commit", "quality-high", branch="main", repo=repo), parent("main", branch="feature/x", repo=repo)])
|
||||||
self.run_gates_runner(["--gates", str(GATES)])
|
self.run_gates_runner(["--gates", str(GATES)])
|
||||||
self.assertEqual(self.finished(), [("done", 0), ("done", 0)])
|
self.assertEqual(self.finished(), [("done", 0), ("done", 0)])
|
||||||
with closing(sqlite3.connect(self.db)) as db:
|
with closing(gates.opened(self.db, False, self.db.parent)) as db:
|
||||||
self.assertEqual(gates.resolve(db, repo, "feature/x", "commit"), ("quality-high", "main"))
|
self.assertEqual(gates.resolve(db, repo, "feature/x", "commit"), ("quality-high", "main"))
|
||||||
self.assertEqual(gates.parents_of(db, repo, "feature/x"), [("main", "operator")])
|
self.assertEqual(gates.parents_of(db, repo, "feature/x"), [("main", "operator")])
|
||||||
self.assertEqual([call for call in self.scripts.calls if call[0].endswith("operator.py")], [])
|
self.assertEqual([call for call in self.scripts.calls if call[0].endswith("operator.py")], [])
|
||||||
@@ -163,7 +162,7 @@ class TestRunnerGates(HubCase):
|
|||||||
class TestHubGateView(HubCase):
|
class TestHubGateView(HubCase):
|
||||||
def write_db(self, repo: str) -> Path:
|
def write_db(self, repo: str) -> Path:
|
||||||
db_path = self.kept / "gates.db"
|
db_path = self.kept / "gates.db"
|
||||||
db = gates.connect(db_path)
|
db = gates.opened(db_path, True, self.kept)
|
||||||
db.executemany("INSERT INTO rules VALUES (?, ?, ?, ?, ?)", [(repo, "main", "commit", "test", 0.0), (repo, "main", "merge", "quality-high", 0.0), (repo, "feature", "pr", "e2e", 0.0), (repo, "feature/kept", "merge", "quick", 0.0)])
|
db.executemany("INSERT INTO rules VALUES (?, ?, ?, ?, ?)", [(repo, "main", "commit", "test", 0.0), (repo, "main", "merge", "quality-high", 0.0), (repo, "feature", "pr", "e2e", 0.0), (repo, "feature/kept", "merge", "quick", 0.0)])
|
||||||
db.executemany("INSERT INTO parents VALUES (?, ?, ?, ?)", [(repo, "feature", "main", "recorded"), (repo, "feature/done", "feature", "derived"), (repo, "feature/done", "feature/kept", "operator"), (repo, "feature/kept", "main", "derived")])
|
db.executemany("INSERT INTO parents VALUES (?, ?, ?, ?)", [(repo, "feature", "main", "recorded"), (repo, "feature/done", "feature", "derived"), (repo, "feature/done", "feature/kept", "operator"), (repo, "feature/kept", "main", "derived")])
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -182,7 +181,7 @@ class TestHubGateView(HubCase):
|
|||||||
self.assertEqual(status, 200, body)
|
self.assertEqual(status, 200, body)
|
||||||
shown = json.loads(body)["gates"]
|
shown = json.loads(body)["gates"]
|
||||||
self.assertEqual(sorted(shown[repo]), ["feature", "feature/done", "feature/kept", "main"])
|
self.assertEqual(sorted(shown[repo]), ["feature", "feature/done", "feature/kept", "main"])
|
||||||
with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as db, mock.patch.object(gates, "branches", return_value={name: "0" * 40 for name in shown[repo]}):
|
with closing(gates.opened(db_path, False, self.kept)) as db, mock.patch.object(gates, "branches", return_value={name: "0" * 40 for name in shown[repo]}):
|
||||||
self.assertEqual(shown[repo], gates.show(db, [repo])[repo])
|
self.assertEqual(shown[repo], gates.show(db, [repo])[repo])
|
||||||
done = shown[repo]["feature/done"]
|
done = shown[repo]["feature/done"]
|
||||||
self.assertEqual(done["parents"], [{"parent": "feature", "source": "derived"}, {"parent": "feature/kept", "source": "operator"}])
|
self.assertEqual(done["parents"], [{"parent": "feature", "source": "derived"}, {"parent": "feature/kept", "source": "operator"}])
|
||||||
@@ -190,6 +189,16 @@ class TestHubGateView(HubCase):
|
|||||||
self.assertEqual(db_path.read_bytes(), before)
|
self.assertEqual(db_path.read_bytes(), before)
|
||||||
self.assertEqual(sorted(path.name for path in self.kept.iterdir()), ["gates.db"])
|
self.assertEqual(sorted(path.name for path in self.kept.iterdir()), ["gates.db"])
|
||||||
|
|
||||||
|
def test_a_parent_in_the_kept_journal_of_the_mount_shows_as_recorded(self):
|
||||||
|
root = self.write_tree()
|
||||||
|
repo = f"{root}/repos/github.com/me/demo"
|
||||||
|
self.write_db(repo)
|
||||||
|
row = {"id": "1-1", "repo": repo, "branch": "main", "parent": "feature/kept"}
|
||||||
|
(self.kept / "gate-parents.jsonl").write_text(json.dumps(row) + "\n", encoding="utf-8")
|
||||||
|
status, _, body = self.get("/api/tree")
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
self.assertEqual(json.loads(body)["gates"][repo]["main"]["parents"], [{"parent": "feature/kept", "source": "recorded"}])
|
||||||
|
|
||||||
def test_without_a_database_or_with_a_broken_one_the_tree_carries_no_gates(self):
|
def test_without_a_database_or_with_a_broken_one_the_tree_carries_no_gates(self):
|
||||||
self.write_tree()
|
self.write_tree()
|
||||||
self.assertEqual(json.loads(self.get("/api/tree")[2])["gates"], {})
|
self.assertEqual(json.loads(self.get("/api/tree")[2])["gates"], {})
|
||||||
@@ -199,7 +208,7 @@ class TestHubGateView(HubCase):
|
|||||||
def test_a_stored_level_outside_levels_or_an_older_gates_py_without_parents_of_keeps_the_tree_up_without_gates(self):
|
def test_a_stored_level_outside_levels_or_an_older_gates_py_without_parents_of_keeps_the_tree_up_without_gates(self):
|
||||||
root = self.write_tree()
|
root = self.write_tree()
|
||||||
repo = f"{root}/repos/github.com/me/demo"
|
repo = f"{root}/repos/github.com/me/demo"
|
||||||
db = gates.connect(self.write_db(repo))
|
db = gates.opened(self.write_db(repo), True, self.kept)
|
||||||
db.execute("UPDATE rules SET level = 'bogus' WHERE branch = 'main' AND action = 'commit'")
|
db.execute("UPDATE rules SET level = 'bogus' WHERE branch = 'main' AND action = 'commit'")
|
||||||
db.commit()
|
db.commit()
|
||||||
status, _, body = self.get("/api/tree")
|
status, _, body = self.get("/api/tree")
|
||||||
|
|||||||
Reference in new issue
Block a user