From 07e28e6d72e8b8e3a1cf07287e8e617831fc4864 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 16 Jul 2026 12:58:56 +0200 Subject: [PATCH] Update to Services --- backend/app/server.py | 46 ++++++++++++++++------------------ backend/app/worker.py | 9 +++---- backend/pyproject.toml | 6 +++++ backend/tests/test_contract.py | 2 +- vercel.json | 23 +++++++---------- 5 files changed, 42 insertions(+), 44 deletions(-) diff --git a/backend/app/server.py b/backend/app/server.py index 3953d67..c4a9aba 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -1,19 +1,17 @@ """FastAPI app for the seal durable agent — the UI-facing surface. -Endpoints (mounted under ``/api`` by ``vercel.json``): - - POST /chat run a turn, stream the AI SDK UI message stream - GET /chat/{id}/stream resume an in-flight stream - GET /sessions list sessions - POST /sessions create a session - GET /sessions/{id} session metadata + UI message history - POST /sessions/{id}/title generate a title from the first user message - DELETE /sessions/{id} delete a session - POST /upload, GET /files/{p} private blob upload + proxy - -`vercel dev` serves this app; the workflow worker (`worker.py`) drives the run. -This process also calls ``vercel.workflow.start``, which imports and replays the -workflow modules, so the preamble mirrors ``worker.py``. +Endpoints: + + POST /api/chat run a turn, stream the AI SDK UI message stream + GET /api/chat/{id}/stream resume an in-flight stream + GET /api/sessions list sessions + POST /api/sessions create a session + GET /api/sessions/{id} session metadata + UI message history + POST /api/sessions/{id}/title generate a title from the first user message + DELETE /api/sessions/{id} delete a session + POST /api/upload, GET /api/files/{p} private blob upload + proxy + +The workflow worker (``worker.py``) drives the run. """ from __future__ import annotations @@ -66,7 +64,7 @@ async def lifespan(_app: fastapi.FastAPI) -> collections.abc.AsyncIterator[None] ) -@app.get("/health") +@app.get("/api/health") async def health() -> dict[str, str]: return {"status": "ok"} @@ -79,7 +77,7 @@ class ChatRequest(pydantic.BaseModel): messages: list[ai_sdk.UIMessage] -@app.post("/chat") +@app.post("/api/chat") async def post_chat(request: ChatRequest) -> fastapi.responses.StreamingResponse: messages, approvals = ai_sdk.to_messages(request.messages) await sessions.touch(request.session_id) @@ -120,7 +118,7 @@ async def post_chat(request: ChatRequest) -> fastapi.responses.StreamingResponse ) -@app.get("/chat/{session_id}/stream") +@app.get("/api/chat/{session_id}/stream") async def resume_chat(session_id: str) -> fastapi.responses.Response: # ``useChat({ resume: true })`` GETs this on mount. Re-tail the durable # stream from the in-flight run's start; 204 when nothing is running. @@ -141,17 +139,17 @@ class CreateSessionRequest(pydantic.BaseModel): title: str | None = None -@app.get("/sessions") +@app.get("/api/sessions") async def list_sessions() -> list[sessions.SessionMeta]: return await sessions.list_sessions() -@app.post("/sessions", status_code=201) +@app.post("/api/sessions", status_code=201) async def create_session(body: CreateSessionRequest) -> sessions.SessionMeta: return await sessions.create_session(body.id, title=body.title) -@app.get("/sessions/{session_id}") +@app.get("/api/sessions/{session_id}") async def get_session(session_id: str) -> dict[str, object]: meta = await sessions.get_session(session_id) if meta is None: @@ -165,7 +163,7 @@ async def get_session(session_id: str) -> dict[str, object]: return {**meta.model_dump(), "messages": serialized} -@app.post("/sessions/{session_id}/title") +@app.post("/api/sessions/{session_id}/title") async def generate_title(session_id: str) -> sessions.SessionMeta: meta = await sessions.get_session(session_id) if meta is None: @@ -186,7 +184,7 @@ async def generate_title(session_id: str) -> sessions.SessionMeta: return updated -@app.delete("/sessions/{session_id}") +@app.delete("/api/sessions/{session_id}") async def delete_session(session_id: str) -> dict[str, str]: if not await sessions.delete_session(session_id): raise fastapi.HTTPException(status_code=404, detail="Session not found") @@ -202,7 +200,7 @@ class UploadResponse(pydantic.BaseModel): filename: str -@app.post("/upload") +@app.post("/api/upload") async def upload(file: fastapi.UploadFile) -> UploadResponse: content = await file.read() media_type = file.content_type or "application/octet-stream" @@ -222,7 +220,7 @@ async def upload(file: fastapi.UploadFile) -> UploadResponse: ) -@app.get("/files/{pathname:path}") +@app.get("/api/files/{pathname:path}") async def get_file(pathname: str) -> fastapi.responses.Response: async with AsyncBlobClient() as client: result = await client.get(pathname, access="private") diff --git a/backend/app/worker.py b/backend/app/worker.py index 331524d..2382165 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -1,10 +1,9 @@ """Worker entrypoint for the durable agent. -`vercel dev` runs this as the `__wkf_*` queue consumer. Importing the workflow -modules constructs their `Workflows()` registries, which registers the queue +Importing `agent` constructs the `Workflows()` registry, which registers the queue handlers that actually drive `run_session` / `run_turn` to completion. -The preamble (env defaults + the `ai` sandbox passthrough) must run before the +The preamble (env defaults, log settup) must run before the workflow libraries are imported, so it lives at module top. """ @@ -37,7 +36,7 @@ os.path.join(_BACKEND_DIR, ".seal"), ) -# Importing the driver pulls in turn/session/stream; constructing each module's -# `Workflows()` registers its queue handlers. +# Importing the driver pulls in turn/session/stream. import agent.driver # noqa: E402, F401 import agent.turn # noqa: E402, F401 +from agent import workflow as workflow # noqa: E402, F401 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8205f36..10cc0ef 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,6 +12,12 @@ dependencies = [ "vercel>=0.7.0", ] +[tool.vercel] +entrypoint = "app.server:app" + +[[tool.vercel.workflows]] +entrypoint = "app.worker:workflow" + [tool.mypy] python_version = "3.13" strict = true diff --git a/backend/tests/test_contract.py b/backend/tests/test_contract.py index 303b482..8d96e34 100644 --- a/backend/tests/test_contract.py +++ b/backend/tests/test_contract.py @@ -116,7 +116,7 @@ async def collect() -> list[str]: await sessions.create_session(session_id) transport = httpx.ASGITransport(app=server.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: - response = await client.get(f"/sessions/{session_id}") + response = await client.get(f"/api/sessions/{session_id}") assert response.status_code == 200 ui_messages: list[dict[str, Any]] = response.json()["messages"] return sse, ui_messages diff --git a/vercel.json b/vercel.json index 8936234..79d703a 100644 --- a/vercel.json +++ b/vercel.json @@ -1,20 +1,15 @@ { - "fluid": true, - "experimentalServices": { + "services": { "frontend": { - "framework": "vite", - "entrypoint": "frontend", - "routePrefix": "/" + "root": "frontend/" }, "backend": { - "framework": "fastapi", - "entrypoint": "backend/app/server.py", - "routePrefix": "/api" - }, - "agent": { - "type": "worker", - "entrypoint": "backend/app/worker.py", - "topics": ["__wkf_*"] + "root": "backend/", + "entrypoint": "pyproject.toml" } - } + }, + "rewrites": [ + { "source": "/api/(.*)", "destination": { "service": "backend" } }, + { "source": "/(.*)", "destination": { "service": "frontend" } } + ] }