Files
ai-agent/backend/newproject/policy.py
Gabriel Vidal deb01427d5 feat(backend): New projects — owner and guests start a project from a template
newproject/: the policy (guestDefaults.newProjects — quota, templates, the
ones the owner approves first, visibility, level, domain — and a per-guest
createProjects + address bucket), the New project form built from it, and the
flow: POST /api/new-project/form (the run's guesses as defaults) → submit →
shape/policy and host checks (a problem sends a corrected form back) → an
owner ask for a backend template → sidecar /scaffold → grant → /relocate +
resume the same session inside the project with an app-authored turn.
SpawnBody.newProject starts a guest's run in ~/projects/.new-project/<uid>
(staging_scope: /tmp only); /api/me reports newProjects; the thread follows
ConversationDetail.continuedIn.

Fix: a guest's turn into an existing conversation resumed with no cwd, so the
sidecar ran it in the lab checkout (its CLAUDE.md loaded) — it now resumes
where the session lives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:42:07 +02:00

126 lines
5.3 KiB
Python

"""
Who may start a project, and what the *New project* form offers them.
* The **owner** always may: every app template under ``TEMPLATES_DIR``, any
address bucket (``dev`` by default), every visibility, no quota, no
approval.
* A **guest** may once the owner turned ``createProjects`` on for them
(Settings → Users), within ``guestDefaults.newProjects``
(``users.store.DEFAULT_NEW_PROJECTS``): a quota of projects, the templates
offered — the ones in ``approve`` wait for the owner's OK, since they put a
backend on the zipgo host — the visibility choices, and always their own
address bucket: ``<name>.<bucket>.<domain>``, the bucket being their id
unless the owner set another.
"""
from __future__ import annotations
from templates.catalog import TEMPLATES_DIR
from users.caller import OWNER_ID, OWNER_NAME, Caller
# Templates another generator owns (a homelab service, a Claude Code skill):
# the New project form only offers app scaffolds.
_NOT_APPS = ("homelab-service", "skill-cli")
OWNER_BUCKETS = ("dev", "game")
VISIBILITY_LABELS = {
"public": "Public — anyone with the link",
"password": "Password — a login page in front of the whole site (you get the password)",
"geo": "France + Germany only — visitors from elsewhere get a 404",
}
# What each known template is, in a line a guest can choose from; anything
# else falls back to the catalog's description.
TEMPLATE_BLURBS = {
"react-vite": "a static site or web app — Vite + React + Tailwind, no server",
"vite-react-pocketbase": "an app with sign-in and saved data — a PWA with "
"its own PocketBase backend, works offline",
"datakit": "a Python data project (collect → process → publish), not a website",
}
def app_templates() -> list[str]:
"""Every template the generator can scaffold as an app: a root
``package.json`` or ``pyproject.toml``, not owned by another generator."""
if not TEMPLATES_DIR.is_dir():
return []
out = []
for d in sorted(TEMPLATES_DIR.iterdir()):
if not d.is_dir() or d.name.startswith(".") or d.name.startswith(_NOT_APPS):
continue
if (d / "package.json").is_file() or (d / "pyproject.toml").is_file():
out.append(d.name)
return out
def template_blurb(name: str) -> str:
if name in TEMPLATE_BLURBS:
return TEMPLATE_BLURBS[name]
from templates.catalog import list_templates
for t in list_templates():
if t["name"] == name:
return " ".join((t.get("description") or "").replace("*", "").split())[:140]
return ""
def policy(deps) -> dict:
return deps.users.defaults()["newProjects"]
def bucket_of(user: dict) -> str:
return (user.get("bucket") or "").strip() or user["id"]
def options(deps, requester: str) -> dict:
"""What the form offers ``requester`` (``owner`` or a guest id)."""
pol = policy(deps)
apps = app_templates()
if requester == OWNER_ID:
return {"templates": apps, "approve": [], "visibility": list(VISIBILITY_LABELS),
"visibilityDefault": "public", "bucket": None,
"buckets": list(OWNER_BUCKETS), "domain": pol["domain"],
"level": "ship", "quota": None}
user = deps.users.get(requester) or {"id": requester}
templates = [t for t in pol["templates"] if t in apps]
return {"templates": templates,
"approve": [t for t in pol["approve"] if t in templates],
"visibility": pol["visibility"],
"visibilityDefault": pol["visibilityDefault"],
"bucket": bucket_of(user), "buckets": [bucket_of(user)],
"domain": pol["domain"], "level": pol["level"], "quota": pol["quota"]}
def access(deps, caller: Caller) -> dict:
"""``/api/me``'s ``newProjects``: may the caller start a project now?"""
pol = policy(deps)
if caller.is_owner:
return {"allowed": True, "reason": None, "remaining": None,
"bucket": None, "domain": pol["domain"]}
user = deps.users.active(caller.id) or {}
out = {"allowed": False, "reason": None, "remaining": None,
"bucket": bucket_of(user) if user else None, "domain": pol["domain"]}
if not user.get("createProjects"):
out["reason"] = "not enabled"
return out
if not options(deps, caller.id)["templates"]:
out["reason"] = "no template is offered to guests"
return out
used = deps.new_projects.used(caller.id)
out["remaining"] = max(0, pol["quota"] - used)
if out["remaining"] <= 0:
out["reason"] = f"you've created {used} projects, the most you can"
return out
out["allowed"] = True
return out
def scope_line(deps, caller: Caller) -> str:
"""The guest run's system-prompt line about starting projects."""
a = access(deps, caller)
if a["allowed"]:
return ("- They may start new projects (" + f"{a['remaining']} left): when they "
"ask for one, follow the `shared:new-frontend-project` skill — it opens "
"the *New project* form; never scaffold or deploy a new project by hand.")
if a["reason"] == "not enabled":
return ("- They can't start new projects: if they ask for one, say "
f"{OWNER_NAME} can enable it for them.")
return f"- They can't start a new project right now: {a['reason']}."