feat(family): platforms declared at inception and detected from bound repos (milestone 463 step 2, #4988)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 1m10s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Python tests (push) Successful in 2m40s
CI & Build / Build & push image (push) Failing after 44s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 1m10s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Python tests (push) Successful in 2m40s
CI & Build / Build & push image (push) Failing after 44s
A project's platforms decide which family ideas reach it. This step makes membership answerable from every door: - services/platforms.py: the global catalog (writes are admin-only and duplicate-gated by slug); pure marker detection; and membership reads and writes. Detection only ADDS, and only where nobody has answered. It never overrides a declared or rejected row and never removes one. - coverage: the archive scan now carries every path, and the refresh runs detection fail-open. - inception: a platforms choice (slugs, or null for unanswered). The list is the whole answer: members left out of it become rejected. - MCP: list_platforms and set_project_platforms; enter_project and get_project carry the project's platforms. - REST: /api/platforms (admin writes) and /api/projects/<id>/platforms. - UI: a platforms checklist on the inception card, a Family tab on ProjectView, and a Platforms admin tab in Settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
"""Platforms — what a project is built on or ships as, and which projects are
|
||||
which (milestone 463 step 2).
|
||||
|
||||
Membership is what makes "is this idea in family for that project?" a lookup
|
||||
rather than a judgment: a family idea is for some platforms, and it reaches
|
||||
every project that is a member of one of them.
|
||||
|
||||
Three ways a project becomes, or refuses to become, a member:
|
||||
|
||||
- **declared** — a person said so: at inception, or in the project's settings.
|
||||
- **detected** — a marker file in a bound repo said so, found by the coverage
|
||||
refresh that already walks the repo archive.
|
||||
- **rejected** — a person said NO. Kept as a row, so the next refresh does not
|
||||
detect it straight back.
|
||||
|
||||
The one invariant everything here protects: **detection only ever ADDS, and
|
||||
only where nobody has answered.** It never overwrites a declared or rejected
|
||||
row, and it never removes anything — not even a detected row whose marker has
|
||||
since disappeared, because membership is what adoption rows hang off and a
|
||||
platform flickering in and out with a repo's file tree would churn a ledger
|
||||
of decisions nobody re-made.
|
||||
|
||||
The catalog itself is global, like the canonical area catalog, and for the
|
||||
same reason writes to it are admin-only: a shared vocabulary anyone can extend
|
||||
stops being shared. Reads are open to any signed-in user.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.family import Platform, ProjectPlatform
|
||||
from scribe.services import access
|
||||
from scribe.services.canonical_systems import canonical_slug
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The states that make a project a member. `rejected` is an answer, not
|
||||
# membership.
|
||||
MEMBER_STATES = ("declared", "detected")
|
||||
# What a person may set from a door. `detected` is the refresh's to write.
|
||||
SETTABLE_STATES = ("declared", "rejected")
|
||||
|
||||
|
||||
# --- the catalog -------------------------------------------------------------
|
||||
|
||||
async def list_platforms() -> list[Platform]:
|
||||
"""The whole live catalog, in display order. Global — no owner filter."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Platform)
|
||||
.where(Platform.deleted_at.is_(None))
|
||||
.order_by(Platform.order_index.asc(), Platform.name.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
def _clean_markers(markers) -> list[str]:
|
||||
"""Markers as stored: stripped, non-empty strings, de-duplicated in order.
|
||||
Anything else is refused by the caller before it gets here."""
|
||||
seen: list[str] = []
|
||||
for m in markers or []:
|
||||
m = str(m).strip()
|
||||
if m and m not in seen:
|
||||
seen.append(m)
|
||||
return seen
|
||||
|
||||
|
||||
def validate_markers(markers) -> str | None:
|
||||
"""The error a markers value would earn, or None. Pure."""
|
||||
if markers is None:
|
||||
return None
|
||||
if not isinstance(markers, list) or not all(isinstance(m, str) for m in markers):
|
||||
return "markers must be a list of glob patterns"
|
||||
if any(m.strip().startswith("/") for m in markers):
|
||||
return "markers are repo-relative — no leading slash"
|
||||
return None
|
||||
|
||||
|
||||
async def create_platform(
|
||||
user_id: int, name: str, *, description: str | None = None,
|
||||
markers: list[str] | None = None,
|
||||
) -> Platform | dict | None:
|
||||
"""Add a platform to the global catalog. Admin only.
|
||||
|
||||
Duplicate-gated on the slug, so "Android App" cannot be added beside
|
||||
"Android app": the existing entry comes back instead of a second spelling
|
||||
of it. None means not permitted, or no usable name.
|
||||
"""
|
||||
if not await access.is_instance_admin(user_id):
|
||||
return None
|
||||
slug = canonical_slug(name)
|
||||
if not slug:
|
||||
return None
|
||||
error = validate_markers(markers)
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
async with async_session() as session:
|
||||
existing = await session.scalar(
|
||||
select(Platform).where(Platform.slug == slug, Platform.deleted_at.is_(None))
|
||||
)
|
||||
if existing is not None:
|
||||
return {
|
||||
"duplicate": True,
|
||||
"existing_id": existing.id,
|
||||
"message": (
|
||||
f"'{existing.name}' (#{existing.id}) is already this platform — "
|
||||
f"both names reduce to '{slug}'."
|
||||
),
|
||||
}
|
||||
highest = await session.scalar(
|
||||
select(Platform.order_index).order_by(Platform.order_index.desc()).limit(1)
|
||||
)
|
||||
entry = Platform(
|
||||
name=" ".join(name.split()),
|
||||
slug=slug,
|
||||
description=description,
|
||||
markers=_clean_markers(markers),
|
||||
order_index=(highest or 0) + 1,
|
||||
)
|
||||
session.add(entry)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
async def update_platform(user_id: int, platform_id: int, **fields: object) -> Platform | None:
|
||||
"""Rename, re-describe, re-order or re-mark a catalog entry. Admin only.
|
||||
|
||||
A rename recomputes the slug — the display name and the match key must not
|
||||
disagree. Changing the slug of a platform projects already belong to is
|
||||
safe: membership is by id; only a backup carries the slug.
|
||||
"""
|
||||
if not await access.is_instance_admin(user_id):
|
||||
return None
|
||||
if "markers" in fields:
|
||||
error = validate_markers(fields["markers"])
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
async with async_session() as session:
|
||||
entry = await session.get(Platform, platform_id)
|
||||
if entry is None or entry.deleted_at is not None:
|
||||
return None
|
||||
if fields.get("name"):
|
||||
entry.name = " ".join(str(fields["name"]).split())
|
||||
entry.slug = canonical_slug(entry.name)
|
||||
if fields.get("description") is not None:
|
||||
entry.description = fields["description"] or None
|
||||
if fields.get("order_index") is not None:
|
||||
entry.order_index = int(fields["order_index"])
|
||||
if fields.get("markers") is not None:
|
||||
entry.markers = _clean_markers(fields["markers"])
|
||||
entry.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
async def resolve_slugs(slugs: list[str]) -> tuple[dict[str, int], list[str]]:
|
||||
"""{slug: id} for the slugs the live catalog knows, and the ones it does
|
||||
not. Doors take slugs — readable, stable across installs, and the form an
|
||||
inception record and a backup both carry."""
|
||||
wanted = [s for s in dict.fromkeys(slugs or [])]
|
||||
if not wanted:
|
||||
return {}, []
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(Platform.slug, Platform.id).where(
|
||||
Platform.slug.in_(wanted), Platform.deleted_at.is_(None),
|
||||
)
|
||||
)).all()
|
||||
known = {slug: pid for slug, pid in rows}
|
||||
return known, [s for s in wanted if s not in known]
|
||||
|
||||
|
||||
# --- detection (pure) --------------------------------------------------------
|
||||
|
||||
def marker_matches(marker: str, path: str) -> bool:
|
||||
"""Whether one marker matches one repo-relative path.
|
||||
|
||||
A marker with no slash matches a file's BASENAME anywhere in the tree
|
||||
(`go.mod`, `AndroidManifest.xml`, `vite.config.*`). A marker with a slash
|
||||
matches the whole repo-relative path (`.github/workflows/*`), so a
|
||||
directory-shaped marker cannot be satisfied by a same-named file
|
||||
somewhere else.
|
||||
"""
|
||||
marker = marker.strip()
|
||||
if not marker:
|
||||
return False
|
||||
if "/" in marker:
|
||||
return fnmatch.fnmatchcase(path, marker)
|
||||
return fnmatch.fnmatchcase(path.rsplit("/", 1)[-1], marker)
|
||||
|
||||
|
||||
def detect(catalog: list, paths: list[str]) -> list[int]:
|
||||
"""The ids of every catalog platform at least one of whose markers matches
|
||||
at least one path. Pure, so it is testable against a fixture tree with no
|
||||
forge and no database. A platform with no markers is never detected —
|
||||
that is what declare-only means."""
|
||||
hits: list[int] = []
|
||||
for platform in catalog:
|
||||
markers = list(getattr(platform, "markers", None) or [])
|
||||
if markers and any(marker_matches(m, p) for m in markers for p in paths):
|
||||
hits.append(platform.id)
|
||||
return hits
|
||||
|
||||
|
||||
# --- membership ----------------------------------------------------------------
|
||||
|
||||
async def project_platforms(user_id: int, project_id: int) -> list[dict] | None:
|
||||
"""Every platform the project has an answer for, joined to the catalog.
|
||||
None when the caller cannot read the project.
|
||||
|
||||
Rejected rows are included — a settings screen has to show "no" as well
|
||||
as "yes", or it cannot let anyone change their mind.
|
||||
"""
|
||||
if not await access.can_read_project(user_id, project_id):
|
||||
return None
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(
|
||||
select(ProjectPlatform, Platform)
|
||||
.join(Platform, Platform.id == ProjectPlatform.platform_id)
|
||||
.where(ProjectPlatform.project_id == project_id, Platform.deleted_at.is_(None))
|
||||
.order_by(Platform.order_index.asc(), Platform.name.asc())
|
||||
)).all()
|
||||
return [
|
||||
{"id": p.id, "slug": p.slug, "name": p.name, "state": m.state}
|
||||
for m, p in rows
|
||||
]
|
||||
|
||||
|
||||
def members(platforms: list[dict]) -> list[dict]:
|
||||
"""The rows of `project_platforms` that are membership — the brief form
|
||||
enter_project and get_project carry."""
|
||||
return [
|
||||
{"slug": p["slug"], "name": p["name"], "state": p["state"]}
|
||||
for p in platforms if p["state"] in MEMBER_STATES
|
||||
]
|
||||
|
||||
|
||||
def validate_updates(updates) -> str | None:
|
||||
"""The error a set of membership answers would earn, or None. Pure.
|
||||
|
||||
`updates` is {slug: "declared" | "rejected" | None}. None withdraws the
|
||||
answer — the row goes, and detection may add the platform again later.
|
||||
`detected` is not settable: it is what the refresh writes, and a person
|
||||
claiming it would erase the difference between the two."""
|
||||
if not isinstance(updates, dict):
|
||||
return "platforms must be an object of {slug: state}"
|
||||
for slug, state in updates.items():
|
||||
if not isinstance(slug, str) or not slug:
|
||||
return "each platform is named by its slug"
|
||||
if state is not None and state not in SETTABLE_STATES:
|
||||
return (
|
||||
f"'{state}' is not a state a person sets — use one of "
|
||||
f"{', '.join(SETTABLE_STATES)}, or null to withdraw the answer"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def set_project_platforms(
|
||||
user_id: int, project_id: int, updates: dict[str, str | None],
|
||||
) -> list[dict]:
|
||||
"""Apply a person's answers about a project's platforms. Write-gated on
|
||||
the project (rule 78). Platforms not named are left exactly as they are.
|
||||
|
||||
Raises ValueError, naming the problem, on a malformed update, an unknown
|
||||
slug, or no write access — before anything is written, so the update
|
||||
applies whole or not at all.
|
||||
"""
|
||||
error = validate_updates(updates)
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
if not await access.can_write_project(user_id, project_id):
|
||||
raise ValueError(f"project {project_id} not found or no write access")
|
||||
known, unknown = await resolve_slugs(list(updates))
|
||||
if unknown:
|
||||
raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)")
|
||||
async with async_session() as session:
|
||||
existing = {
|
||||
row.platform_id: row for row in (await session.execute(
|
||||
select(ProjectPlatform).where(ProjectPlatform.project_id == project_id)
|
||||
)).scalars().all()
|
||||
}
|
||||
for slug, state in updates.items():
|
||||
pid = known[slug]
|
||||
row = existing.get(pid)
|
||||
if state is None:
|
||||
if row is not None:
|
||||
await session.delete(row)
|
||||
elif row is None:
|
||||
session.add(ProjectPlatform(project_id=project_id, platform_id=pid, state=state))
|
||||
else:
|
||||
row.state = state
|
||||
await session.commit()
|
||||
return await project_platforms(user_id, project_id) or []
|
||||
|
||||
|
||||
async def record_detected(project_id: int, platform_ids: list[int]) -> list[int]:
|
||||
"""Add `detected` membership for each platform the project has NO answer
|
||||
for. Returns the ids actually added.
|
||||
|
||||
The refresh's writer, so it takes no user: it runs on the owner's behalf
|
||||
inside a sync the owner's keyring authorised. It never updates or deletes
|
||||
a row — see the module docstring for why that is the whole contract.
|
||||
"""
|
||||
if not platform_ids:
|
||||
return []
|
||||
async with async_session() as session:
|
||||
answered = set((await session.execute(
|
||||
select(ProjectPlatform.platform_id).where(
|
||||
ProjectPlatform.project_id == project_id,
|
||||
)
|
||||
)).scalars().all())
|
||||
added = [pid for pid in dict.fromkeys(platform_ids) if pid not in answered]
|
||||
for pid in added:
|
||||
session.add(ProjectPlatform(project_id=project_id, platform_id=pid, state="detected"))
|
||||
await session.commit()
|
||||
return added
|
||||
|
||||
|
||||
async def detect_for_project(project_id: int, paths: list[str]) -> list[int]:
|
||||
"""Run detection over a refresh's paths and record what is new. The
|
||||
coverage refresh's single call — it must not be able to fail that
|
||||
refresh, so the caller wraps it."""
|
||||
hits = detect(await list_platforms(), paths)
|
||||
added = await record_detected(project_id, hits)
|
||||
if added:
|
||||
logger.info("project %s: detected platform(s) %s", project_id, added)
|
||||
return added
|
||||
|
||||
Reference in New Issue
Block a user