Files
ai-agent/backend/runs/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

309 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Spawn / resume / fork / interrupt routes."""
import uuid as uuidlib
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import accounts as accounts_mod
import conversations
import schemas
from conversations.catalog import _resolve_transcript, _summary_and_meta
from core.http import _r
from core.state import State
from newproject import policy as np_policy
from newproject import service as np_service
from runs.service import _send_message, _session_worker
from runs.sidecar_client import _sidecar_busy, _sidecar_call, _valid_sid
from users import access, deliver
from users import runs as guest_runs
from users.caller import OWNER_ID, Caller, CallerDep
router = APIRouter()
class SpawnBody(BaseModel):
prompt: str
model: str | None = None
# Which agent harness runs the session: "claude" (the claude CLI, default)
# or "pi" (the pi.dev runner — see services/ai-agent/runner/).
harness: str | None = None
# Whether the session thinks. False ⇒ the sidecar runs the harness with
# extended thinking off (`--thinking disabled` / `--thinking off`); unset ⇒
# each CLI keeps its own default. Both harnesses support it.
thinking: bool | None = None
# How hard a claude run works a turn (`--effort`: low|medium|high|xhigh|max).
# Claude-only — it's what the composer shows instead of the thinking toggle
# for claude models; unset ⇒ the CLI's own default.
effort: str | None = None
# Which Claude account the run bills ("personal"/"work" — accounts.py).
# The composer's account chip; unset ⇒ routed from ``projects``.
account: str | None = None
# The location tags the composer had on (project slugs). A project owned
# by a non-default account routes the spawn there and starts it in that
# account's repo instead of the lab checkout.
projects: list[str] | None = None
# Host working dir for the run; unset ⇒ the account's own (else the
# sidecar's default, the lab checkout).
cwd: str | None = None
# Which runner launches it: a paired worker's id, "local" for the host
# sidecar, unset ⇒ the account's online worker if it has one, else local.
worker: str | None = None
# The composer's *+ New project* pick: the run starts a project from one
# of the owner's templates (newproject/) instead of working in one — a
# guest's then needs no granted project.
newProject: bool = False
class ResumeBody(BaseModel):
sessionId: str
prompt: str
model: str | None = None
cwd: str | None = None
harness: str | None = None # unset ⇒ the harness the session started on
thinking: bool | None = None # unset ⇒ what the session last ran with
effort: str | None = None # unset ⇒ what the session last ran with
# Accepted for symmetry but never switches a session: its transcript only
# exists in the account it started on (see _session_account).
account: str | None = None
# Resume a worker session even though its terminal looks live (the
# confirm behind a 409 ``terminal_live``).
takeover: bool = False
@router.post("/api/spawn", responses=_r(schemas.SpawnResult))
def spawn_conversation(deps: State, caller: CallerDep, body: SpawnBody):
"""Kick off a new headless agent session on the host (see _send_message).
A guest's spawn is pinned server-side (users/runs.py): one granted
project as cwd, the default account, the host sidecar, the claude
harness, an allowed model, the guest's write scope — whatever the
composer sent."""
if body.newProject:
return _spawn_new_project(deps, caller, body)
if not caller.is_owner:
slugs = guest_runs.check_projects(caller, body.projects, "chat")
slug = slugs[0]
model = guest_runs.check_model(caller, body.model)
guest_runs.check_budget(deps, caller)
out = _send_message(
deps, guest_runs.tag_prompt(caller, body.prompt), model=model,
harness="claude", thinking=body.thinking, effort=body.effort,
account=accounts_mod.DEFAULT, projects=slugs,
cwd=guest_runs.project_cwd(slug), worker="local",
scope=guest_runs.scope_for(caller, slug, caller.level(slug),
guest_runs.shared_skills(deps), slugs[1:],
new_projects=np_policy.scope_line(deps, caller)),
stamp={"owner": caller.id, "projects": slugs})
if not caller.impersonator: # the owner testing as a guest knows
deliver.owner_ping_start(deps, caller, slug, body.prompt)
return out
return _send_message(deps, body.prompt, model=body.model, harness=body.harness,
thinking=body.thinking, effort=body.effort,
account=body.account, projects=body.projects,
cwd=body.cwd, worker=body.worker)
def _spawn_new_project(deps, caller: Caller, body: SpawnBody) -> dict:
"""A *New project* spawn: the run's one job is the New project form (the
new-frontend-project skill); newproject/ takes it from the submit. A
guest's starts in an empty staging dir, writing nowhere but /tmp; the
owner's runs like any of theirs, with the same note."""
access = np_policy.access(deps, caller)
if not access["allowed"]:
raise HTTPException(403, {"message": f"you can't start a project: {access['reason']}",
"kind": "new_project"})
if caller.is_owner:
return _send_message(deps, body.prompt + np_service.staging_note(caller),
model=body.model, harness="claude", thinking=body.thinking,
effort=body.effort, account=body.account, cwd=body.cwd,
worker="local", stamp={"newProject": True})
model = guest_runs.check_model(caller, body.model)
guest_runs.check_budget(deps, caller)
out = _send_message(
deps, guest_runs.tag_prompt(caller, body.prompt) + np_service.staging_note(caller),
model=model, harness="claude", thinking=body.thinking, effort=body.effort,
account=accounts_mod.DEFAULT, projects=[], worker="local",
cwd=np_service.staging_cwd(caller.id),
scope=guest_runs.staging_scope(caller, guest_runs.shared_skills(deps),
np_policy.scope_line(deps, caller)),
stamp={"owner": caller.id, "projects": [], "newProject": True})
if not caller.impersonator:
deliver.owner_ping_start(deps, caller, "a new project", body.prompt)
return out
def _guest_scope(deps, caller: Caller, sid: str) -> dict:
"""The scope a guest's turn relaunches a session with: their own on the
conversation's project when they hold ``chat`` there, else /tmp only (an
owner's conversation shared to talk about it, not to edit with)."""
meta = deps.meta_store.peek(sid)
slug = access.project_of(meta)
skills = guest_runs.shared_skills(deps)
line = np_policy.scope_line(deps, caller)
if slug and caller.can(slug, "chat"):
extra = guest_runs.extra_projects(caller, meta.get("projects"))
return guest_runs.scope_for(caller, slug, caller.level(slug), skills, extra,
new_projects=line)
if meta.get("newProject") and meta.get("owner") == caller.id:
# Their New project run, before the project exists.
return guest_runs.staging_scope(caller, skills, line)
return {**guest_runs.scope_for(caller, slug or "-", None, skills), "writeDirs": ["/tmp"]}
@router.post("/api/resume", responses=_r(schemas.SpawnResult))
def resume_conversation(deps: State, caller: CallerDep, body: ResumeBody):
"""Continue an existing conversation with a new user turn (see _send_message).
Runs ``claude -p <prompt> --resume <sessionId>`` in the session's original
cwd; resume reuses the session id, so Claude appends the new turns to the
same transcript the viewer is already watching and the open conversation
streams the continuation live. A still-running session is stopped first —
sending a message always wins.
In a conversation several people share (a guest's, or one shared with
guests) every turn carries its author's tag; a guest's turn relaunches
the run with their scope and never with a client-chosen cwd."""
sid = _valid_sid(body.sessionId)
if not caller.is_owner:
access.ensure_visible(deps, caller, sid)
if not access.can_send(deps, caller, sid):
raise HTTPException(403, "you can read this conversation but not write in it")
guest_runs.check_budget(deps, caller)
model = guest_runs.check_model(caller, body.model) if body.model else None
# Never a client-chosen cwd — and never none either: the sidecar's
# default is the lab checkout, whose CLAUDE.md and skills a guest's
# turn must not load. The session's own directory, where it last ran.
return _send_message(deps, guest_runs.tag_prompt(caller, body.prompt),
session_id=sid, model=model, harness="claude",
thinking=body.thinking, effort=body.effort,
takeover=body.takeover,
cwd=np_service.session_cwd(deps, sid),
scope=_guest_scope(deps, caller, sid))
prompt = body.prompt
meta = deps.meta_store.peek(sid)
if access.is_shared(meta):
prompt = guest_runs.tag_prompt(caller, prompt)
out = _send_message(deps, prompt, session_id=sid,
model=body.model, cwd=body.cwd, harness=body.harness,
thinking=body.thinking, effort=body.effort,
takeover=body.takeover)
# Writing in a guest's conversation is taking part in it: the owner now
# hears about it too — unless they muted it on purpose.
if meta.get("owner") and OWNER_ID not in (meta.get("follow") or {}):
access.set_follow(deps, sid, OWNER_ID, True)
return out
class ForkBody(BaseModel):
id: str # source conversation id (<slug>/<sid>.jsonl)
mi: int # ordinal of the user message being edited (fork point)
prompt: str # the edited message — becomes the fork's next turn
model: str | None = None
harness: str | None = None # unset ⇒ the harness the source ran on
@router.post("/api/fork", responses=_r(schemas.SpawnResult))
def fork_conversation(deps: State, caller: CallerDep, body: ForkBody):
"""Branch a new conversation off an existing one at a user message.
The new session's history is the source transcript up to (excluding) user
message ``mi``; ``prompt`` (the edited message) is then sent as its next
turn. Neither CLI can truncate at a message (`claude --fork-session` only
clones whole sessions), so the host sidecar does transcript surgery: it
copies the first ``cutLine`` lines of the source session file under a
fresh session id and resumes that — see sidecar ``/fork``. ``cutText`` and
``cutUserOrd`` let the sidecar/runner verify they're slicing the same
message this viewer showed (the archive is an append-only copy of the
live file, so the line indices match; the canary catches drift)."""
prompt = (body.prompt or "").strip()
if not prompt:
raise HTTPException(400, "empty prompt")
access.ensure_visible(deps, caller, body.id)
ap = _resolve_transcript(body.id)
src_sid, summary, src_meta = _summary_and_meta(deps, body.id)
scope = stamp = None
if not caller.is_owner:
# A guest's fork is theirs, in the source's project, which they need
# chat on — the fork then runs under their scope.
slug = guest_runs.check_project(caller, [access.project_of(src_meta)], "chat")
extra = guest_runs.extra_projects(caller, src_meta.get("projects"))
guest_runs.check_budget(deps, caller)
scope = guest_runs.scope_for(caller, slug, caller.level(slug),
guest_runs.shared_skills(deps), extra)
stamp = {"owner": caller.id, "projects": [slug, *extra]}
if access.is_shared(src_meta) or stamp:
prompt = guest_runs.tag_prompt(caller, prompt)
if not src_sid:
raise HTTPException(409, "source conversation has no session id")
cut = conversations.find_cut_line(ap, body.mi)
if cut is None:
raise HTTPException(404, "fork point not found — message ordinal "
"missing or not a user message")
if cut["line"] == 0:
raise HTTPException(400, "nothing before the first message — start a "
"new conversation instead")
harness = (body.harness or src_meta.get("harness") or "claude").lower()
# The fork is written next to the source transcript, in the source's
# account — it can't move to another one.
account = (accounts_mod.resolve(summary, src_meta) if harness == "claude"
else accounts_mod.DEFAULT)
# …and on the runner it lives on: the surgery happens next to the source.
run_on = _session_worker(deps, src_sid) if harness == "claude" else None
sid = str(uuidlib.uuid4())
out = _sidecar_call(deps, "/fork", {
"sessionId": sid, "srcSessionId": src_sid,
"cwd": summary.get("cwd"),
"cutLine": cut["line"], "cutText": cut["text"][:120],
"cutUserOrd": cut["userOrd"],
"prompt": prompt, "model": body.model, "harness": harness,
"account": account, "scope": scope}, worker=run_on)
deps.meta_store.update(sid, {
"state": "running", "harness": harness, "account": account,
"worker": run_on,
"forkedFrom": {"id": body.id, "sessionId": src_sid, "mi": body.mi},
**(stamp or {})})
deps.hub.publish({"type": "meta", "id": sid})
return {"sessionId": sid, "pid": out.get("pid")}
class InterruptBody(BaseModel):
sessionId: str
@router.post("/api/interrupt", responses=_r(schemas.InterruptResult))
def interrupt_conversation(deps: State, caller: CallerDep, body: InterruptBody):
"""Stop a running Claude session this viewer spawned.
Proxies to the host sidecar, which sends the run's process group a SIGINT —
exactly like pressing Esc in a terminal. Claude aborts the turn and records a
``[Request interrupted by user]`` marker in the transcript, which the parser
then surfaces as the ``interrupted`` state. We also stamp the metadata
immediately so the UI flips before that marker has synced."""
sid = _valid_sid(body.sessionId)
if not caller.is_owner:
access.ensure_visible(deps, caller, sid)
if not access.can_send(deps, caller, sid):
raise HTTPException(403, "you can read this conversation but not stop it")
# Shield the kill from the exit watcher: the vanish it causes must resolve
# to the "interrupted" stamped below, not a generic "finished".
_sidecar_busy.add(sid)
try:
try:
out = _sidecar_call(deps, "/interrupt", {"sessionId": sid},
worker=_session_worker(deps, sid))
except HTTPException as e:
# 404 from the sidecar = no live process for this session (it
# already exited, or was started from a terminal), passed through
# by _sidecar_call. A stale "running" badge over a dead run is
# exactly what the user is clearing here, so stamp it interrupted
# anyway.
if e.status_code != 404:
raise
out = {"note": "no live process — marked interrupted"}
deps.meta_store.update(sid, {"state": "interrupted"})
finally:
_sidecar_busy.discard(sid)
deps.hub.publish({"type": "meta", "id": sid})
return {"sessionId": sid, "ok": True, **out}