Files
ai-agent/backend/newproject/store.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

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)