feat(moments): rules mount on moments, through every rule door (milestone 458 step 3, #4921)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m0s
CI & Build / Python tests (push) Successful in 1m51s
CI & Build / Build & push image (push) Successful in 29s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m0s
CI & Build / Python tests (push) Successful in 1m51s
CI & Build / Build & push image (push) Successful in 29s
rule_moments (migration 0117) records which moments a rule arrives at, by catalog name, cascading with the rule. rule_detail, the one seam every rule door already returns through, gains moments beside system_ids: None leaves the mounts alone, a list replaces them. get_rule and both list_rules doors read them back, batched per page. All five MCP rule/preference writes and the three REST ones take moments and validate them before their create or update. An unknown name is refused with the catalog listed and leaves no half-made rule behind; a parity test pins that ordering on every door. Backup v22 carries the mounts as a join table remapped through the rule map; a real-Postgres round trip checks they land on the restored rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from scribe.auth import get_current_user_id, login_required
|
||||
import scribe.services.moments as moments_svc
|
||||
import scribe.services.rulebooks as rulebooks_svc
|
||||
from scribe.services.trash import delete as trash_delete
|
||||
from scribe.services.rule_usage import (
|
||||
@@ -17,6 +18,15 @@ from scribe.services.rule_usage import (
|
||||
rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
def _moments_or_refusal(data: dict):
|
||||
"""The request's `moments`, validated BEFORE any write — so an unknown
|
||||
name is a 400 and not a rule created without the mounts it asked for.
|
||||
Absent means "leave them alone" (None), as on the MCP door."""
|
||||
try:
|
||||
return moments_svc.require_moments(data.get("moments")), None
|
||||
except ValueError as exc:
|
||||
return None, (jsonify({"error": str(exc)}), 400)
|
||||
|
||||
|
||||
# ── Rulebooks ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -154,8 +164,12 @@ async def list_rules():
|
||||
# than for snippets: every rule on every install predates this table, so
|
||||
# for a while the zero-filled shape IS the common case.
|
||||
usage = await usage_for_rules([int(it["id"]) for it in items])
|
||||
# Zero-filled like `usage`, for the same reason: the UI renders "mounted
|
||||
# nowhere" from an empty list rather than treating a missing key as a state.
|
||||
mounted = await rulebooks_svc.list_rule_moments([int(it["id"]) for it in items])
|
||||
for it in items:
|
||||
it["usage"] = usage.get(int(it["id"]), empty_rule_usage())
|
||||
it["moments"] = mounted.get(int(it["id"]), [])
|
||||
return jsonify({"rules": items})
|
||||
|
||||
|
||||
@@ -167,6 +181,9 @@ async def create_rule(topic_id: int):
|
||||
statement = (data.get("statement") or "").strip()
|
||||
if not title or not statement:
|
||||
return jsonify({"error": "title and statement are required"}), 400
|
||||
moments, refused = _moments_or_refusal(data)
|
||||
if refused:
|
||||
return refused
|
||||
try:
|
||||
rule = await rulebooks_svc.create_rule(
|
||||
topic_id=topic_id,
|
||||
@@ -189,7 +206,7 @@ async def create_rule(topic_id: int):
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return jsonify(await rulebooks_svc.rule_detail(
|
||||
get_current_user_id(), rule, data.get("system_ids"),
|
||||
get_current_user_id(), rule, data.get("system_ids"), moments,
|
||||
)), 201
|
||||
|
||||
|
||||
@@ -223,10 +240,15 @@ async def update_rule(rule_id: int):
|
||||
# service normalises "" to NULL for every nullable text column. The MCP
|
||||
# door needs the explicit list only because "" already means "unchanged"
|
||||
# there — two idioms, one outcome.
|
||||
moments, refused = _moments_or_refusal(data)
|
||||
if refused:
|
||||
return refused
|
||||
rule = await rulebooks_svc.update_rule(rule_id, uid, **fields)
|
||||
if rule is None:
|
||||
return jsonify({"error": "rule not found"}), 404
|
||||
return jsonify(await rulebooks_svc.rule_detail(uid, rule, data.get("system_ids")))
|
||||
return jsonify(await rulebooks_svc.rule_detail(
|
||||
uid, rule, data.get("system_ids"), moments,
|
||||
))
|
||||
|
||||
|
||||
@rulebooks_bp.get("/rules/<int:rule_id>/versions")
|
||||
@@ -401,6 +423,9 @@ async def create_project_rule(project_id: int):
|
||||
"surfaces at the moment it applies."
|
||||
}), 400
|
||||
title = (data.get("title") or "").strip() or statement.split(".")[0][:50]
|
||||
moments, refused = _moments_or_refusal(data)
|
||||
if refused:
|
||||
return refused
|
||||
try:
|
||||
rule = await rulebooks_svc.create_project_rule(
|
||||
project_id=project_id,
|
||||
@@ -423,7 +448,7 @@ async def create_project_rule(project_id: int):
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return jsonify(await rulebooks_svc.rule_detail(
|
||||
get_current_user_id(), rule, data.get("system_ids"),
|
||||
get_current_user_id(), rule, data.get("system_ids"), moments,
|
||||
)), 201
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user