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>
309 lines
16 KiB
Python
309 lines
16 KiB
Python
"""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}
|