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>
128 lines
5.3 KiB
Python
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
|