CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 48s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Failing after 57s
CI & Build / Build & push image (push) Skipped
Step 1 found the reason the standard names never held, and it is sharper than "prose doesn't fire": the list WAS real and it WAS seeded — but only on the inception path, for a project with zero Systems. Ad-hoc create_system never consulted it, which is how Forge minted "CI and Release" and Portal minted "CI & release" after the constant already existed. This wires the vocabulary to the moment that mints a name. - services/systems.assess_system_name: the local duplicate gate AND the catalog lookup, in ONE service function both doors call. The gate lived only in the MCP tool, which is exactly how the web UI shipped without a check the agent surface enforced (#2482). REST now answers 409 with the System that already covers the area. - An `exact` catalog hit is APPLIED (mechanical — the names differ only in spelling). An `overlap` is only OFFERED, on both doors: applying a judgment call silently is how a cross-project rule surfaces in the wrong project. - canonical_systems.best_overlap is the ONE scorer behind the create-time offer and the review sweep, so the two surfaces can never name different areas for one System. It also takes the catalog the caller already holds, so the review is not an N+1. UI (folded in from step 1 — rule 27, that step shipped with no human surface): - SystemsSection: a Shared area picker on create and edit, the area on each card, and a collapsed review of proposals that appears only when there is something to decide. `exact` and `overlap` never share a style — one is mechanical, the other is the reviewer's judgment, and presenting them alike is how a wrong mapping gets waved through. - Settings → Admin → Areas: the catalog itself, showing each entry's slug, because the slug is what decides whether two names are the same area and a rename moves it. - A picker rather than a live matcher: reproducing the slug rule in TypeScript would give this feature two matchers to keep in step — the exact drift the catalog exists to end. The server stays authoritative. tests/helpers.fake_system gains canonical_id=None: an unnamed attribute is an auto-MagicMock and therefore truthy, which is the trap that helper exists for (note 2109) and a nullable FK walks straight into it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
357 lines
14 KiB
Python
357 lines
14 KiB
Python
"""System management + record<->system associations.
|
|
|
|
A System is a per-project, reusable, self-describing subsystem/area. Access is
|
|
governed by the project's permission via services/access.py (multi-user ACL
|
|
rule) — never a bare owner filter. Records (notes/tasks/issues) link to systems
|
|
many-to-many through record_systems, mutable over time.
|
|
"""
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import delete, func, select
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.note import Note
|
|
from scribe.models.system import RecordSystem, System
|
|
from scribe.services import access
|
|
from scribe.services import canonical_systems as canonical_systems_svc
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def local_name_key(name: str) -> str:
|
|
"""The within-project uniqueness key: case and spacing, nothing else.
|
|
|
|
Deliberately weaker than `canonical_slug`. This one answers "is this the
|
|
same System I already have here", where the operator's own spelling is the
|
|
thing being compared; the canonical slug answers "is this the same AREA as
|
|
some other project's System", where spelling is exactly what must be
|
|
ignored.
|
|
"""
|
|
return " ".join(name.split()).lower()
|
|
|
|
|
|
async def assess_system_name(user_id: int, project_id: int, name: str) -> dict:
|
|
"""What BOTH doors must know before minting a System name (milestone 307).
|
|
|
|
Lived in the MCP tool alone until now, which is how the web UI shipped
|
|
without a gate the agent surface enforced (#2482). One service function, so
|
|
the two doors cannot answer the same question differently (rule 33).
|
|
|
|
Returns `{"duplicate": …|None, "canonical": …|None}`:
|
|
|
|
- `duplicate` — this project already has a System by that name. A hard stop
|
|
for the caller: a second one splits the area's records across two piles.
|
|
- `canonical` — the global catalog covers this area, with a `basis`.
|
|
`exact` is mechanical and safe to apply on the spot; `overlap` is a
|
|
judgment call and must be OFFERED, never applied. Neither ever blocks:
|
|
an unmatched name is a project-specific area, which is legitimate.
|
|
|
|
Fails open on both arms — a naming aid must never break a create.
|
|
"""
|
|
out: dict = {"duplicate": None, "canonical": None}
|
|
key = local_name_key(name)
|
|
if not key:
|
|
return out
|
|
try:
|
|
for existing in await list_systems(user_id, project_id, include_archived=True):
|
|
if local_name_key(existing.name) == key:
|
|
out["duplicate"] = {"id": existing.id, "name": existing.name}
|
|
return out
|
|
except Exception:
|
|
logger.debug("system name assessment: local scan failed", exc_info=True)
|
|
return out
|
|
try:
|
|
exact = await canonical_systems_svc.find_by_name(name)
|
|
if exact is not None:
|
|
out["canonical"] = {
|
|
"id": exact.id, "name": exact.name, "basis": "exact",
|
|
}
|
|
return out
|
|
# No exact hit: fall back to the same overlap scoring the review
|
|
# surface uses, so a create-time offer and a later proposal never
|
|
# disagree about which area a name resembles.
|
|
near = await canonical_systems_svc.best_overlap(name)
|
|
if near is not None:
|
|
out["canonical"] = near
|
|
except Exception:
|
|
logger.debug("system name assessment: catalog lookup failed", exc_info=True)
|
|
return out
|
|
|
|
|
|
async def standard_systems() -> list[tuple[str, str]]:
|
|
"""The standard cross-project vocabulary (#2798) as (name, charter) pairs.
|
|
|
|
Reads the GLOBAL canonical catalog (milestone 307). This was a tuple
|
|
constant in this module until the catalog became a table: a constant
|
|
cannot be a foreign key, so nothing outside a project could reference an
|
|
area, and the list only ever applied on the inception-seed path — which is
|
|
how three spellings of "CI & Release" reached one instance anyway.
|
|
"""
|
|
return [(entry.name, entry.description or "") for entry in
|
|
await canonical_systems_svc.list_canonical_systems()]
|
|
|
|
|
|
async def seed_standard_systems(user_id: int, project_id: int) -> list[System]:
|
|
"""Mint the standard starter set for a project that has NO Systems yet
|
|
(milestone 297). Idempotent: a project with any System — the vocabulary
|
|
already started, standard or not — gets nothing; the duplicate gate and
|
|
the project's own judgment take it from there. [] without write access.
|
|
|
|
Seeded Systems are mapped to their catalog entry as they are created, so a
|
|
project born this way needs no reconciliation pass later."""
|
|
if await list_systems(user_id, project_id, include_archived=True):
|
|
return []
|
|
out: list[System] = []
|
|
for index, entry in enumerate(await canonical_systems_svc.list_canonical_systems()):
|
|
system = await create_system(
|
|
user_id, project_id, entry.name, description=entry.description,
|
|
order_index=index, canonical_id=entry.id,
|
|
)
|
|
if system is None:
|
|
break
|
|
out.append(system)
|
|
return out
|
|
|
|
|
|
async def create_system(
|
|
user_id: int,
|
|
project_id: int,
|
|
name: str,
|
|
description: str | None = None,
|
|
color: str | None = None,
|
|
order_index: int = 0,
|
|
canonical_id: int | None = None,
|
|
) -> System | None:
|
|
"""Create a System. None if the user can't write the project.
|
|
|
|
`canonical_id` maps the new System onto the global catalog; leaving it None
|
|
is fine — an unmapped System is fully usable, and the mapping can be
|
|
proposed later (services/canonical_systems.propose_mappings).
|
|
"""
|
|
if not await access.can_write_project(user_id, project_id):
|
|
return None
|
|
async with async_session() as session:
|
|
system = System(
|
|
user_id=user_id,
|
|
project_id=project_id,
|
|
name=name.strip(),
|
|
description=description,
|
|
color=color,
|
|
order_index=order_index,
|
|
canonical_id=canonical_id,
|
|
)
|
|
session.add(system)
|
|
await session.commit()
|
|
await session.refresh(system)
|
|
return system
|
|
|
|
|
|
async def get_system(user_id: int, system_id: int) -> System | None:
|
|
"""Fetch a System if the user can read its project."""
|
|
async with async_session() as session:
|
|
system = await session.get(System, system_id)
|
|
if system is None or system.deleted_at is not None:
|
|
return None
|
|
if not await access.can_read_project(user_id, system.project_id):
|
|
return None
|
|
return system
|
|
|
|
|
|
async def list_systems(
|
|
user_id: int, project_id: int, include_archived: bool = False
|
|
) -> list[System]:
|
|
"""A project's systems (active by default). [] if no read access."""
|
|
if not await access.can_read_project(user_id, project_id):
|
|
return []
|
|
async with async_session() as session:
|
|
query = select(System).where(
|
|
System.project_id == project_id,
|
|
System.deleted_at.is_(None),
|
|
)
|
|
if not include_archived:
|
|
query = query.where(System.status == "active")
|
|
query = query.order_by(System.order_index.asc(), System.created_at.asc())
|
|
result = await session.execute(query)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def update_system(user_id: int, system_id: int, **fields: object) -> System | None:
|
|
"""Update a System if the user can write its project."""
|
|
# canonical_id is deliberately NOT settable here: canonical_systems.
|
|
# set_system_canonical is its single writer, because it also validates the
|
|
# catalog entry is live. Two entry points onto one column is the drift this
|
|
# table exists to end.
|
|
allowed = {"name", "description", "color", "status", "order_index"}
|
|
async with async_session() as session:
|
|
system = await session.get(System, system_id)
|
|
if system is None or system.deleted_at is not None:
|
|
return None
|
|
if not await access.can_write_project(user_id, system.project_id):
|
|
return None
|
|
for key, value in fields.items():
|
|
if key in allowed and value is not None:
|
|
setattr(system, key, value)
|
|
system.updated_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
await session.refresh(system)
|
|
return system
|
|
|
|
|
|
async def archive_system(user_id: int, system_id: int) -> System | None:
|
|
return await update_system(user_id, system_id, status="archived")
|
|
|
|
|
|
async def delete_system(user_id: int, system_id: int) -> bool:
|
|
"""Soft-delete a System (recoverable). Requires project write access."""
|
|
async with async_session() as session:
|
|
system = await session.get(System, system_id)
|
|
if system is None or system.deleted_at is not None:
|
|
return False
|
|
if not await access.can_write_project(user_id, system.project_id):
|
|
return False
|
|
system.deleted_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
return True
|
|
|
|
|
|
# --- record <-> system associations (many-to-many, mutable) ---
|
|
|
|
async def set_record_systems(
|
|
user_id: int, note_id: int, system_ids: list[int]
|
|
) -> list[int] | None:
|
|
"""Replace a record's system associations with `system_ids` (set semantics).
|
|
|
|
Returns the resulting associated system ids, or None if the user can't write
|
|
the record. Silently drops ids that don't exist or the user can't read —
|
|
associations only link accessible systems.
|
|
"""
|
|
if not await access.can_write_note(user_id, note_id):
|
|
return None
|
|
async with async_session() as session:
|
|
wanted: list[int] = []
|
|
for sid in dict.fromkeys(system_ids): # de-dup, preserve order
|
|
system = await session.get(System, sid)
|
|
if system is None or system.deleted_at is not None:
|
|
continue
|
|
if not await access.can_read_project(user_id, system.project_id):
|
|
continue
|
|
wanted.append(sid)
|
|
|
|
existing = set(
|
|
(
|
|
await session.execute(
|
|
select(RecordSystem.system_id).where(RecordSystem.note_id == note_id)
|
|
)
|
|
).scalars().all()
|
|
)
|
|
wanted_set = set(wanted)
|
|
|
|
to_remove = existing - wanted_set
|
|
if to_remove:
|
|
await session.execute(
|
|
delete(RecordSystem).where(
|
|
RecordSystem.note_id == note_id,
|
|
RecordSystem.system_id.in_(to_remove),
|
|
)
|
|
)
|
|
for sid in wanted:
|
|
if sid not in existing:
|
|
session.add(RecordSystem(note_id=note_id, system_id=sid))
|
|
await session.commit()
|
|
return wanted
|
|
|
|
|
|
async def list_record_systems(user_id: int, note_id: int) -> list[System]:
|
|
"""Systems associated with a record (if the user can read it)."""
|
|
if not await access.can_read_note(user_id, note_id):
|
|
return []
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(System)
|
|
.join(RecordSystem, RecordSystem.system_id == System.id)
|
|
.where(RecordSystem.note_id == note_id, System.deleted_at.is_(None))
|
|
.order_by(System.order_index.asc(), System.name.asc())
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def list_records_for_system(
|
|
user_id: int, system_id: int, kind: str | None = None, open_only: bool = False
|
|
) -> list[Note]:
|
|
"""Records associated with a System. `kind` filters task_kind (e.g. 'issue');
|
|
`open_only` limits to tasks not done/cancelled. [] if no read access."""
|
|
system = await get_system(user_id, system_id)
|
|
if system is None:
|
|
return []
|
|
async with async_session() as session:
|
|
query = (
|
|
select(Note)
|
|
.join(RecordSystem, RecordSystem.note_id == Note.id)
|
|
.where(RecordSystem.system_id == system_id, Note.deleted_at.is_(None))
|
|
)
|
|
if kind:
|
|
query = query.where(Note.task_kind == kind)
|
|
if open_only:
|
|
query = query.where(Note.status.not_in(["done", "cancelled"]))
|
|
query = query.order_by(Note.updated_at.desc())
|
|
result = await session.execute(query)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def count_open_issues(user_id: int, project_id: int) -> int:
|
|
"""Count open (not done/cancelled) issues in a project. 0 if no read access."""
|
|
if not await access.can_read_project(user_id, project_id):
|
|
return 0
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(func.count(Note.id)).where(
|
|
Note.project_id == project_id,
|
|
Note.task_kind == "issue",
|
|
Note.status.isnot(None),
|
|
Note.status.not_in(["done", "cancelled"]),
|
|
Note.deleted_at.is_(None),
|
|
)
|
|
)
|
|
return int(result.scalar() or 0)
|
|
|
|
|
|
async def open_issue_counts_by_system(user_id: int, project_id: int) -> dict[int, int]:
|
|
"""{system_id: open-issue count} for all systems in a project, in one query.
|
|
{} if no read access."""
|
|
if not await access.can_read_project(user_id, project_id):
|
|
return {}
|
|
async with async_session() as session:
|
|
rows = await session.execute(
|
|
select(RecordSystem.system_id, func.count(Note.id))
|
|
.join(Note, Note.id == RecordSystem.note_id)
|
|
.join(System, System.id == RecordSystem.system_id)
|
|
.where(
|
|
System.project_id == project_id,
|
|
Note.task_kind == "issue",
|
|
Note.status.isnot(None),
|
|
Note.status.not_in(["done", "cancelled"]),
|
|
Note.deleted_at.is_(None),
|
|
)
|
|
.group_by(RecordSystem.system_id)
|
|
)
|
|
return {sid: cnt for sid, cnt in rows.fetchall()}
|
|
|
|
|
|
async def list_issues(user_id: int, project_id: int, open_only: bool = True) -> list[Note]:
|
|
"""Issues in a project (open by default), newest first. [] if no read access."""
|
|
if not await access.can_read_project(user_id, project_id):
|
|
return []
|
|
async with async_session() as session:
|
|
query = select(Note).where(
|
|
Note.project_id == project_id,
|
|
Note.task_kind == "issue",
|
|
Note.status.isnot(None),
|
|
Note.deleted_at.is_(None),
|
|
)
|
|
if open_only:
|
|
query = query.where(Note.status.not_in(["done", "cancelled"]))
|
|
query = query.order_by(Note.updated_at.desc())
|
|
result = await session.execute(query)
|
|
return list(result.scalars().all())
|