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>
95 lines
4.8 KiB
Python
95 lines
4.8 KiB
Python
"""
|
|
The *New project* form: the run's guesses as defaults, the requester's policy
|
|
as the choices. The same form for the owner and for guests — a guest's
|
|
address bucket is fixed (their own), the owner picks one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
from newproject.policy import VISIBILITY_LABELS, template_blurb
|
|
from users.caller import OWNER_NAME
|
|
|
|
TITLE = "New project"
|
|
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
|
|
|
|
|
def slugify(s: str, limit: int = 40) -> str:
|
|
return _SLUG_RE.sub("-", (s or "").lower()).strip("-")[:limit].strip("-")
|
|
|
|
|
|
def _help(base: str, field: str, problems: list[dict]) -> str:
|
|
msgs = [p["message"] for p in problems if p.get("field") == field]
|
|
return "\n".join([*(f"⚠ {m}" for m in msgs), base]) if msgs else base
|
|
|
|
|
|
def fields(opts: dict, guesses: dict, problems: list[dict] | None = None) -> list[dict]:
|
|
problems = problems or []
|
|
name = slugify(guesses.get("name") or guesses.get("title") or "") or "my-project"
|
|
templates = opts["templates"]
|
|
template = guesses.get("template") if guesses.get("template") in templates else templates[0]
|
|
visibility = (guesses.get("visibility") if guesses.get("visibility") in opts["visibility"]
|
|
else opts["visibilityDefault"])
|
|
label = slugify(guesses.get("label") or "", 63) or name
|
|
out: list[dict] = []
|
|
if problems:
|
|
out.append({"type": "markdown", "label": "Not created yet",
|
|
"text": "Fix this and submit again:\n\n"
|
|
+ "\n".join(f"- {p['message']}" for p in problems)})
|
|
if (guesses.get("summary") or "").strip():
|
|
out.append({"type": "markdown", "label": "What I understood",
|
|
"text": guesses["summary"].strip()[:1500]})
|
|
out += [
|
|
{"key": "name", "label": "Project name", "type": "text", "required": True,
|
|
"default": name,
|
|
"help": _help("Lowercase letters, digits and dashes — the project's folder "
|
|
"and repository.", "name", problems)},
|
|
{"key": "title", "label": "Title", "type": "text", "required": True,
|
|
"default": (guesses.get("title") or name.replace("-", " ").title())[:80],
|
|
"help": "Shown in the browser tab and as the page heading."},
|
|
{"key": "description", "label": "Description", "type": "textarea",
|
|
"default": (guesses.get("description") or "")[:400],
|
|
"help": "A sentence or two — the page's description and the README's intro."},
|
|
{"key": "template", "label": "Start from", "type": "radio", "required": True,
|
|
"other": False, "default": template,
|
|
"options": [{"value": t, "label": f"**{t}** — {template_blurb(t)}"
|
|
+ (f" · *{OWNER_NAME} approves it first*" if t in opts["approve"] else "")}
|
|
for t in templates],
|
|
"help": _help("One of the project templates — the agent builds from there.",
|
|
"template", problems)},
|
|
]
|
|
if opts.get("bucket"):
|
|
out.append({"key": "label", "label": "Web address", "type": "text", "required": True,
|
|
"default": label,
|
|
"help": _help(f"The site goes live at **https://{label}.{opts['bucket']}"
|
|
f".{opts['domain']}** — `<this>.{opts['bucket']}."
|
|
f"{opts['domain']}`.", "label", problems)})
|
|
else:
|
|
out += [
|
|
{"key": "label", "label": "Web address", "type": "text", "required": True,
|
|
"default": label,
|
|
"help": _help(f"`<this>.<under>.{opts['domain']}` — usually the project name.",
|
|
"label", problems)},
|
|
{"key": "bucket", "label": "Under", "type": "radio", "required": True,
|
|
"other": True, "default": guesses.get("bucket") or opts["buckets"][0],
|
|
"options": [{"value": b, "label": f"`{b}` — `<address>.{b}.{opts['domain']}`"}
|
|
for b in opts["buckets"]],
|
|
"help": _help("`dev` for apps and tools, `game` for games — or type another.",
|
|
"subdomain", problems)},
|
|
]
|
|
if len(opts["visibility"]) > 1:
|
|
out.append({"key": "visibility", "label": "Who can open it", "type": "radio",
|
|
"required": True, "other": False, "default": visibility,
|
|
"options": [{"value": v, "label": VISIBILITY_LABELS[v]}
|
|
for v in opts["visibility"]]})
|
|
return out
|
|
|
|
|
|
def description(opts: dict, requester_name: str | None) -> str:
|
|
where = (f"`<address>.{opts['bucket']}.{opts['domain']}`" if opts.get("bucket")
|
|
else f"`<address>.<under>.{opts['domain']}`")
|
|
return ("Pre-filled from the request — change anything, then submit. The project "
|
|
f"is created from a template, hosted at {where}, and this conversation "
|
|
"carries on inside it to build the first version.")
|