inception states the legacy stamp and the retired rulebook keys as facts about stored records; family keeps the repeat-trigger calibration numbers without the date and record they were measured on, and drops the instance that taught the per-kind comparison. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
274 lines
12 KiB
Python
274 lines
12 KiB
Python
"""Project inception — what a project was decided to inherit.
|
|
|
|
A project's inheritance is a decision, not a default. The record lives on
|
|
``projects.inception``::
|
|
|
|
{
|
|
"decided_at": "<iso>", "decided_by": <user id> | null,
|
|
"via": "mcp" | "ui" | "legacy",
|
|
"choices": {
|
|
"design_system_id": <id> | null,
|
|
"seed_systems": bool,
|
|
"platforms": [<slug>, ...] | null
|
|
}
|
|
}
|
|
|
|
NULL = undecided → enter_project asks. ``legacy`` is the migration's stamp on
|
|
projects that predate inception records (inherit-all / no design system /
|
|
no seed), so the ask fires only for projects created since.
|
|
|
|
Rules are not a choice: a rule in a rulebook is global and applies to every
|
|
project, and a project's own rules are written on it directly. Migration 0101
|
|
strips the retired ``exclude_always_on_rulebooks`` and ``subscribe_rulebooks``
|
|
keys from stored records.
|
|
|
|
The shape and its validator are pure; ``decide`` composes the existing
|
|
services — set_project_design_system and the standard Systems seed — checks
|
|
every target BEFORE touching anything,
|
|
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`` 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
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.project import Project
|
|
|
|
INCEPTION_VIAS = ("mcp", "ui", "legacy")
|
|
CHOICE_KEYS = ("design_system_id", "seed_systems", "platforms")
|
|
|
|
|
|
def validate_inception(choices) -> str | None:
|
|
"""The structural error an inception ``choices`` object would earn, or
|
|
None. Pure and checked BEFORE any effect is applied: a decision either
|
|
applies whole or errors whole.
|
|
|
|
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"
|
|
unknown = sorted(set(choices) - set(CHOICE_KEYS))
|
|
if unknown:
|
|
return f"unknown inception choice(s): {', '.join(unknown)} (one of: {', '.join(CHOICE_KEYS)})"
|
|
ds = choices.get("design_system_id")
|
|
if ds is not None and (isinstance(ds, bool) or not isinstance(ds, int) or ds <= 0):
|
|
return "design_system_id must be a positive id or null"
|
|
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:
|
|
"""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
|
|
),
|
|
}
|
|
|
|
|
|
def is_decided(project) -> bool:
|
|
"""A project is decided once its inception record exists (any via)."""
|
|
return bool(getattr(project, "inception", None))
|
|
|
|
|
|
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>,
|
|
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. `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
|
|
|
|
project = await projects_svc.get_project(user_id, project_id)
|
|
if project is None:
|
|
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 [],
|
|
}
|
|
|
|
|
|
async def _check_targets(user_id: int, choices: dict) -> None:
|
|
"""Every id a decision names must be the caller's (or readable) BEFORE any
|
|
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(
|
|
user_id: int,
|
|
project_id: int,
|
|
*,
|
|
choices: dict | None,
|
|
via: str,
|
|
) -> dict:
|
|
"""Record a project's inception decision and apply it.
|
|
|
|
Owner-only. Validates the choices (pure) and every target (readable)
|
|
first; then, each idempotent: point the project at the design system
|
|
(None = explicitly none), seed the standard Systems if asked and the
|
|
project has none; then write ``projects.inception`` LAST. Re-deciding
|
|
replaces the design system and re-seeds nothing a project already has.
|
|
|
|
Returns {"inception": <record>, "effects": {design_system_id,
|
|
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
|
|
from scribe.services import systems as systems_svc
|
|
|
|
if via not in INCEPTION_VIAS or via == "legacy":
|
|
raise ValueError("via must be 'mcp' or 'ui' ('legacy' is the migration's stamp)")
|
|
error = validate_inception(choices or {})
|
|
if error:
|
|
raise ValueError(error)
|
|
choices = normalize_choices(choices)
|
|
project = await projects_svc.get_project(user_id, project_id) # owner-scoped
|
|
if project is None:
|
|
raise ValueError(f"project {project_id} not found (or not yours)")
|
|
await _check_targets(user_id, choices)
|
|
|
|
if not await design_systems_svc.set_project_design_system(
|
|
user_id, project_id, choices["design_system_id"]
|
|
):
|
|
raise ValueError("could not set the design system (no write on the project?)")
|
|
seeded = (
|
|
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(),
|
|
"decided_by": user_id,
|
|
"via": via,
|
|
"choices": choices,
|
|
}
|
|
async with async_session() as session:
|
|
row = await session.get(Project, project_id)
|
|
row.inception = record
|
|
row.updated_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
return {
|
|
"inception": record,
|
|
"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 — the sibling of the
|
|
systems-bootstrap ask: the project's OWN current
|
|
defaults, what to ask the operator, and the exact call that answers it.
|
|
Fail-open: a hint must never break the call it rides on."""
|
|
try:
|
|
defaults = await current_defaults(user_id, project_id)
|
|
except Exception:
|
|
return {}
|
|
designs = ", ".join(f"{d['title']} (#{d['id']})" for d in defaults["design_systems"]) or "none"
|
|
return {
|
|
"defaults": defaults,
|
|
"ask": (
|
|
"This project has no inception decision: nobody has said what it "
|
|
"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), 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>, "
|
|
"platforms=[<slug>, ...])"
|
|
),
|
|
}
|
|
|