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>
90 lines
3.2 KiB
Python
90 lines
3.2 KiB
Python
"""
|
|
``/data/new-projects.json`` — every *New project* form and what became of it,
|
|
keyed by the form's id:
|
|
|
|
``{"requests": {<formId>: {id, sessionId, requester, status, guesses,
|
|
answers, spec, askId, slug, url, repo, visibility, error, next, createdAt,
|
|
updatedAt}}}``
|
|
|
|
``status`` moves ``pending`` (the form is out) → ``approval`` (a template the
|
|
owner approves first) → ``creating`` → ``created`` | ``failed``; or ends
|
|
``declined`` (the owner said no), ``retried`` (the answers didn't pass the
|
|
checks — ``next`` is the form that replaced it) or ``cancelled``. A guest's
|
|
quota counts what they created and what is on its way.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
|
|
from users.store import _JsonFile
|
|
|
|
COUNTED = ("approval", "creating", "created")
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
class NewProjectStore(_JsonFile):
|
|
def __init__(self, path: str):
|
|
super().__init__(path, {"requests": {}})
|
|
|
|
def create(self, fid: str, session_id: str, requester: str, guesses: dict) -> dict:
|
|
now = _now_iso()
|
|
rec = {"id": fid, "sessionId": session_id, "requester": requester,
|
|
"status": "pending", "guesses": guesses, "createdAt": now,
|
|
"updatedAt": now}
|
|
with self._lock:
|
|
self._data["requests"][fid] = rec
|
|
self._save()
|
|
return dict(rec)
|
|
|
|
def get(self, fid: str) -> dict | None:
|
|
with self._lock:
|
|
r = self._data["requests"].get(fid)
|
|
return dict(r) if r else None
|
|
|
|
def by_ask(self, ask_id: str) -> dict | None:
|
|
with self._lock:
|
|
for r in self._data["requests"].values():
|
|
if r.get("askId") == ask_id:
|
|
return dict(r)
|
|
return None
|
|
|
|
def update(self, fid: str, patch: dict) -> dict | None:
|
|
with self._lock:
|
|
r = self._data["requests"].get(fid)
|
|
if r is None:
|
|
return None
|
|
r.update(patch)
|
|
r["updatedAt"] = _now_iso()
|
|
self._save()
|
|
return dict(r)
|
|
|
|
def claim(self, fid: str, frm: tuple[str, ...], to: str,
|
|
patch: dict | None = None) -> dict | None:
|
|
"""Move a request from one of ``frm`` to ``to`` (merging ``patch``) —
|
|
None when it was in another state (a double submit, an approval
|
|
answered twice)."""
|
|
with self._lock:
|
|
r = self._data["requests"].get(fid)
|
|
if r is None or r.get("status") not in frm:
|
|
return None
|
|
r.update(patch or {})
|
|
r["status"] = to
|
|
r["updatedAt"] = _now_iso()
|
|
self._save()
|
|
return dict(r)
|
|
|
|
def all(self) -> list[dict]:
|
|
with self._lock:
|
|
return sorted((dict(r) for r in self._data["requests"].values()),
|
|
key=lambda r: r.get("createdAt") or "", reverse=True)
|
|
|
|
def used(self, requester: str) -> int:
|
|
"""How much of ``requester``'s quota is spent."""
|
|
with self._lock:
|
|
return sum(1 for r in self._data["requests"].values()
|
|
if r.get("requester") == requester and r.get("status") in COUNTED)
|