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:
@@ -8,7 +8,8 @@ A project's inheritance is a decision, not a default. The record lives on
|
||||
"via": "mcp" | "ui" | "legacy",
|
||||
"choices": {
|
||||
"design_system_id": <id> | null,
|
||||
"seed_systems": bool
|
||||
"seed_systems": bool,
|
||||
"platforms": [<slug>, ...] | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +30,15 @@ applies the effects (each idempotent), and writes the record LAST, so a
|
||||
half-applied decision is re-runnable rather than recorded as done.
|
||||
``current_defaults`` is what the enter_project ask shows: what binds today
|
||||
if nobody decides.
|
||||
|
||||
``platforms`` (milestone 463) is which platforms the project IS — the answer
|
||||
that decides which family ideas reach it. A list of catalog SLUGS, not ids:
|
||||
the record is JSON, and a slug survives a backup restore onto an install whose
|
||||
ids differ, where an id inside JSON would come back naming another platform.
|
||||
NULL means the question was not answered here, and the project's memberships
|
||||
are left exactly as they are; a list is the full answer — every platform in it
|
||||
is declared, and any platform detection had added that is NOT in it is
|
||||
recorded as a "no", so the next refresh cannot put it back.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -38,7 +48,7 @@ from scribe.models import async_session
|
||||
from scribe.models.project import Project
|
||||
|
||||
INCEPTION_VIAS = ("mcp", "ui", "legacy")
|
||||
CHOICE_KEYS = ("design_system_id", "seed_systems")
|
||||
CHOICE_KEYS = ("design_system_id", "seed_systems", "platforms")
|
||||
|
||||
|
||||
def validate_inception(choices) -> str | None:
|
||||
@@ -46,8 +56,9 @@ def validate_inception(choices) -> str | None:
|
||||
None. Pure and checked BEFORE any effect is applied: a decision either
|
||||
applies whole or errors whole (the StrictArgs lesson, #2709).
|
||||
|
||||
Accepts two keys, each optional: ``design_system_id`` an int or None,
|
||||
``seed_systems`` a bool. Unknown keys are an error — a typo, or a choice
|
||||
Accepts three keys, each optional: ``design_system_id`` an int or None,
|
||||
``seed_systems`` a bool, ``platforms`` a list of slugs or None. Unknown
|
||||
keys are an error — a typo, or a choice
|
||||
the product no longer offers, must not become a silently ignored one."""
|
||||
if not isinstance(choices, dict):
|
||||
return "choices must be an object"
|
||||
@@ -60,16 +71,26 @@ def validate_inception(choices) -> str | None:
|
||||
seed = choices.get("seed_systems", False)
|
||||
if not isinstance(seed, bool):
|
||||
return "seed_systems must be true or false"
|
||||
platforms = choices.get("platforms")
|
||||
if platforms is not None and (
|
||||
not isinstance(platforms, list)
|
||||
or not all(isinstance(p, str) and p.strip() for p in platforms)
|
||||
):
|
||||
return "platforms must be a list of platform slugs (list_platforms), or null"
|
||||
return None
|
||||
|
||||
|
||||
def normalize_choices(choices: dict | None) -> dict:
|
||||
"""Both keys, always present, in canonical form — what gets stored
|
||||
"""Every key, always present, in canonical form — what gets stored
|
||||
and what the UI/agent reads back. Call after validate_inception."""
|
||||
choices = choices or {}
|
||||
platforms = choices.get("platforms")
|
||||
return {
|
||||
"design_system_id": choices.get("design_system_id"),
|
||||
"seed_systems": bool(choices.get("seed_systems", False)),
|
||||
"platforms": (
|
||||
sorted({p.strip() for p in platforms}) if platforms is not None else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -81,11 +102,15 @@ def is_decided(project) -> bool:
|
||||
async def current_defaults(user_id: int, project_id: int) -> dict:
|
||||
"""What the project inherits if nobody decides — the ask's payload.
|
||||
|
||||
{design_system_id, design_systems: [{id,title}], systems: <count>}.
|
||||
{design_system_id, design_systems: [{id,title}], systems: <count>,
|
||||
platforms: [{slug,name}], project_platforms: [{slug,name,state}]}.
|
||||
Instance-agnostic: an install with no design systems shows an empty list,
|
||||
and the ask says so rather than inventing a default.
|
||||
and the ask says so rather than inventing a default. `project_platforms`
|
||||
is what detection has already found (and anything already answered), so
|
||||
the form can start from it.
|
||||
"""
|
||||
from scribe.services import design_systems as design_systems_svc
|
||||
from scribe.services import platforms as platforms_svc
|
||||
from scribe.services import projects as projects_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
|
||||
@@ -94,10 +119,13 @@ async def current_defaults(user_id: int, project_id: int) -> dict:
|
||||
raise ValueError(f"project {project_id} not found")
|
||||
designs = await design_systems_svc.list_design_systems(user_id)
|
||||
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
|
||||
catalog = await platforms_svc.list_platforms()
|
||||
return {
|
||||
"design_system_id": project.design_system_id,
|
||||
"design_systems": [{"id": d.id, "title": d.title} for d in designs],
|
||||
"systems": len(systems),
|
||||
"platforms": [{"slug": p.slug, "name": p.name} for p in catalog],
|
||||
"project_platforms": await platforms_svc.project_platforms(user_id, project_id) or [],
|
||||
}
|
||||
|
||||
|
||||
@@ -106,9 +134,15 @@ async def _check_targets(user_id: int, choices: dict) -> None:
|
||||
effect lands — a decision applies whole or errors whole."""
|
||||
from scribe.services import access
|
||||
|
||||
from scribe.services import platforms as platforms_svc
|
||||
|
||||
ds = choices["design_system_id"]
|
||||
if ds is not None and not await access.can_read_design_system(user_id, ds):
|
||||
raise ValueError(f"design system {ds} not found (or not readable)")
|
||||
if choices["platforms"]:
|
||||
_, unknown = await platforms_svc.resolve_slugs(choices["platforms"])
|
||||
if unknown:
|
||||
raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)")
|
||||
|
||||
|
||||
async def decide(
|
||||
@@ -127,7 +161,8 @@ async def decide(
|
||||
replaces the design system and re-seeds nothing a project already has.
|
||||
|
||||
Returns {"inception": <record>, "effects": {design_system_id,
|
||||
systems_seeded}}.
|
||||
systems_seeded, platforms}} — `platforms` is the project's answers after
|
||||
the decision, or None when the choice was left unstated.
|
||||
"""
|
||||
from scribe.services import design_systems as design_systems_svc
|
||||
from scribe.services import projects as projects_svc
|
||||
@@ -152,6 +187,9 @@ async def decide(
|
||||
await systems_svc.seed_standard_systems(user_id, project_id)
|
||||
if choices["seed_systems"] else []
|
||||
)
|
||||
platforms = None
|
||||
if choices["platforms"] is not None:
|
||||
platforms = await _apply_platforms(user_id, project_id, choices["platforms"])
|
||||
|
||||
record = {
|
||||
"decided_at": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -169,10 +207,41 @@ async def decide(
|
||||
"effects": {
|
||||
"design_system_id": choices["design_system_id"],
|
||||
"systems_seeded": [sy.name for sy in seeded],
|
||||
"platforms": platforms,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _apply_platforms(user_id: int, project_id: int, slugs: list[str]) -> list[dict]:
|
||||
"""The platforms answer as membership. The list is the WHOLE answer:
|
||||
every slug in it is declared, and every platform the project was a member
|
||||
of (declared or detected) that is left out becomes rejected — so the next
|
||||
refresh cannot detect back what the person just said the project isn't.
|
||||
Platforms already rejected stay rejected; platforms never answered stay
|
||||
unanswered."""
|
||||
from scribe.services import platforms as platforms_svc
|
||||
|
||||
named = set(slugs)
|
||||
current = await platforms_svc.project_platforms(user_id, project_id) or []
|
||||
updates: dict[str, str | None] = {slug: "declared" for slug in named}
|
||||
for row in current:
|
||||
if row["slug"] not in named and row["state"] in platforms_svc.MEMBER_STATES:
|
||||
updates[row["slug"]] = "rejected"
|
||||
return await platforms_svc.set_project_platforms(user_id, project_id, updates)
|
||||
|
||||
|
||||
def _platform_line(defaults: dict) -> str:
|
||||
"""What the ask says about platforms: what detection already found, and
|
||||
the catalog to choose from."""
|
||||
found = [
|
||||
p["slug"] for p in defaults.get("project_platforms", [])
|
||||
if p["state"] in ("declared", "detected")
|
||||
]
|
||||
catalog = ", ".join(p["slug"] for p in defaults.get("platforms", [])) or "none"
|
||||
lead = f"detected so far: {', '.join(found)}; " if found else ""
|
||||
return f"{lead}catalog: {catalog}"
|
||||
|
||||
|
||||
async def inception_ask(user_id: int, project_id: int) -> dict:
|
||||
"""The enter_project ask for an undecided project (milestone 297) — the
|
||||
sibling of the systems-bootstrap ask (#2683): the project's OWN current
|
||||
@@ -190,13 +259,16 @@ async def inception_ask(user_id: int, project_id: int) -> dict:
|
||||
"inherits. Design system — "
|
||||
f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} "
|
||||
f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
|
||||
"once: which design system (or none), and whether to seed "
|
||||
"the standard starter Systems — then record the answers. This ask repeats on "
|
||||
"every enter_project until a decision is recorded."
|
||||
"once: which design system (or none), whether to seed "
|
||||
"the standard starter Systems, and which platforms the project is "
|
||||
f"built on or ships as ({_platform_line(defaults)}) — then record the "
|
||||
"answers. This ask repeats on every enter_project until a decision "
|
||||
"is recorded."
|
||||
),
|
||||
"call": (
|
||||
f"decide_project_inception(project_id={project_id}, "
|
||||
"design_system_id=<id | -1 for none>, seed_systems=<true|false>)"
|
||||
"design_system_id=<id | -1 for none>, seed_systems=<true|false>, "
|
||||
"platforms=[<slug>, ...])"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user