Files
ai-agent/backend/forms/routes.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

128 lines
5.3 KiB
Python

"""Forms: rich structured questions answered in the PWA."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import schemas
from conversations.catalog import _conv_ids_by_session
from core.http import _json_in_worker, _r
from core.state import State
from newproject import service as newproject_service
from forms.service import _create_form
from users import access
from users.caller import Caller, CallerDep
router = APIRouter()
# ── forms: rich structured questions answered in the PWA (forms.py) ──────────
class FormCreateBody(BaseModel):
title: str
fields: list[dict]
description: str | None = None
type: str | None = None
url: str | None = None
sessionId: str | None = None
id: str | None = None
# True: the hub pushes the "📋 <title>" notification itself, deep-linked
# to the conversation with ?form=<id> (the notify CLI's way; the legacy
# skill sent its own push after creating the form).
notify: bool | None = None
# Files the spec references as ``{{asset:<name>}}`` (an html block's image,
# video…), inlined base64 by the CLI so the same payload works through a
# worker's outbox: ``[{name, contentType?, data}]``. Stored like composer
# uploads and substituted by their serve URL before validation.
assets: list[dict] | None = None
class FormSubmitBody(BaseModel):
answers: dict
@router.post("/api/forms", responses=_r(schemas.FormRecord))
def form_create(deps: State, body: FormCreateBody):
"""Create a form (the ask-form skill's entry point) and return it.
The caller then sends its own push notification linking to the
conversation with ``?form=<id>`` and long-polls GET /api/forms/{id}."""
return _create_form(deps, body.model_dump())
@router.get("/api/forms", responses=_r(schemas.FormsResponse))
@_json_in_worker
def forms_list(deps: State, caller: CallerDep, session: str = "", limit: int = 100):
"""Forms, newest first; ``session`` filters to one conversation. Each form
with a known transcript carries ``conversationId`` — its way back to the
conversation it belongs to — and ``forYou``: whether the caller is in its
conversation's audience (the home page's *waiting for feedback* shows
only those; the owner still sees every form)."""
items = deps.form_store.list(session_id=session or None, limit=limit)
if not caller.is_owner:
items = [f for f in items if f.get("sessionId")
and access.visible(deps, caller, f["sessionId"])]
for f in items:
sid = f.get("sessionId") or ""
f["forYou"] = (access.hears(caller, deps.meta_store.peek(sid)) if sid
else caller.is_owner)
if any(f.get("sessionId") for f in items):
by_sid = _conv_ids_by_session(deps, )
for f in items:
cid = by_sid.get(f.get("sessionId") or "")
if cid:
f["conversationId"] = cid
return {"forms": items, "count": len(items)}
def _form_visible(deps, caller: Caller, form: dict | None) -> dict:
"""The form, if ``caller`` may see it — a guest only their conversations'."""
if not form or not (caller.is_owner or (
form.get("sessionId")
and access.visible(deps, caller, form["sessionId"]))):
raise HTTPException(404, "unknown form")
return form
@router.get("/api/forms/{form_id}", responses=_r(schemas.FormRecord))
def form_get(deps: State, caller: CallerDep, form_id: str, waitSecs: float = 0):
"""The form's current state; ``waitSecs`` long-polls for a submit."""
if not caller.is_owner:
_form_visible(deps, caller, deps.form_store.get(form_id))
form = (deps.form_store.wait(form_id, waitSecs) if waitSecs > 0
else deps.form_store.get(form_id))
if not form:
raise HTTPException(404, "unknown form")
return form
@router.post("/api/forms/{form_id}/submit", responses=_r(schemas.FormRecord))
def form_submit(deps: State, caller: CallerDep, form_id: str, body: FormSubmitBody):
"""Record the user's answers (from the PWA's form card / modal)."""
_form_visible(deps, caller, deps.form_store.get(form_id))
try:
form = deps.form_store.submit(form_id, body.answers,
by=None if caller.is_owner else caller.id)
except ValueError as e:
raise HTTPException(400, str(e))
if not form:
raise HTTPException(404, "unknown form")
deps.hub.publish({"type": "form", "id": form_id,
"sessionId": form.get("sessionId") or ""})
# A New project form: the answers start the project (on a thread).
newproject_service.on_form_submitted(deps, form)
return form
@router.post("/api/forms/{form_id}/cancel", responses=_r(schemas.FormRecord))
def form_cancel(deps: State, caller: CallerDep, form_id: str):
"""Dismiss a form unanswered (the card's Cancel button, or the agent
giving up on the wait)."""
_form_visible(deps, caller, deps.form_store.get(form_id))
form = deps.form_store.cancel(form_id, by=None if caller.is_owner else caller.id)
if not form:
raise HTTPException(404, "unknown form")
newproject_service.on_form_cancelled(deps, form)
deps.hub.publish({"type": "form", "id": form_id,
"sessionId": form.get("sessionId") or ""})
return form