Files
turnstone/turnstone/api/server_spec.py
T
Patrick Buckley b8e51fa9ed fix(api): add DequeueRequest schema for DELETE /workstreams/{ws_id}/send
Copilot review on PR #422 flagged that the DELETE-on-send (dequeue)
EndpointSpec declared no request_model, so the generated OpenAPI
showed no requestBody for an operation that *requires* a JSON body
with ``msg_id`` and 400s when it's missing.

- Add ``DequeueRequest`` to ``server_schemas.py`` with the single
  required ``msg_id: str`` field.
- Wire ``request_model=DequeueRequest`` and ``response_model=
  StatusResponse`` on the DELETE EndpointSpec; trim the now-redundant
  inline body example from the description.
- Re-import the schema in ``server_spec.py`` and add the entry to
  ``_ALL_MODELS`` so the OpenAPI components list carries it.
- Regenerate ``openapi-server.json``.

Sibling thread on the close EndpointSpec was already addressed in
4000ae2 (request_model=CloseWorkstreamRequest).

4558 tests passing under ``-m "not live"``; ruff + mypy clean.
2026-04-26 22:14:22 -07:00

483 lines
15 KiB
Python

"""Server endpoint catalog for OpenAPI spec generation."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from turnstone.api.openapi import EndpointSpec, QueryParam, build_openapi
if TYPE_CHECKING:
from pydantic import BaseModel
from turnstone.api.schemas import (
AuthLoginRequest,
AuthLoginResponse,
AuthSetupRequest,
AuthSetupResponse,
AuthStatusResponse,
AuthWhoamiResponse,
ErrorResponse,
StatusResponse,
)
from turnstone.api.server_schemas import (
ApproveRequest,
AvailableModelInfo,
CancelRequest,
CloseWorkstreamRequest,
CommandRequest,
CreateWorkstreamRequest,
CreateWorkstreamResponse,
DashboardResponse,
DequeueRequest,
HealthResponse,
ListAttachmentsResponse,
ListAvailableModelsResponse,
ListMemoriesResponse,
ListSavedWorkstreamsResponse,
ListSkillSummaryResponse,
ListWorkstreamsResponse,
MemoryInfo,
PlanFeedbackRequest,
SaveMemoryRequest,
SearchMemoriesRequest,
SendRequest,
SendResponse,
SkillSummary,
UploadAttachmentResponse,
WorkstreamDetailResponse,
WorkstreamHistoryResponse,
)
SERVER_ENDPOINTS: list[EndpointSpec] = [
# --- Workstream management ---
EndpointSpec(
"/v1/api/workstreams",
"GET",
"List active workstreams",
response_model=ListWorkstreamsResponse,
tags=["Workstreams"],
),
EndpointSpec(
"/v1/api/dashboard",
"GET",
"Dashboard with workstream details and aggregates",
response_model=DashboardResponse,
tags=["Workstreams"],
),
EndpointSpec(
"/v1/api/workstreams/new",
"POST",
"Create a new workstream",
description=(
"Accepts two content types. Default is `application/json` with a "
"`CreateWorkstreamRequest` body. Alternatively, `multipart/form-data` "
"with one `meta` field (JSON-encoded `CreateWorkstreamRequest` shape) "
"plus zero-or-more `file` parts saves each file as an attachment "
"under the new workstream. When `initial_message` is also set, "
"attachments are reserved onto that turn before the worker thread "
"dispatches; otherwise they remain pending for a follow-up "
"`POST /v1/api/workstreams/{ws_id}/send`."
),
request_model=CreateWorkstreamRequest,
response_model=CreateWorkstreamResponse,
error_codes=[400, 409, 413],
tags=["Workstreams"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}/close",
"POST",
"Close a workstream",
request_model=CloseWorkstreamRequest,
response_model=StatusResponse,
error_codes=[400, 404],
tags=["Workstreams"],
),
# --- Chat ---
EndpointSpec(
"/v1/api/workstreams/{ws_id}/send",
"POST",
"Send a user message",
request_model=SendRequest,
response_model=SendResponse,
error_codes=[400, 404],
tags=["Chat"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}/send",
"DELETE",
"Cancel a queued message",
description=(
"Removes a previously-queued message from the workstream's "
"pending queue. Returns ``status: removed`` when the queue "
"had the entry, ``status: not_found`` otherwise."
),
request_model=DequeueRequest,
response_model=StatusResponse,
error_codes=[400, 404],
tags=["Chat"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}/approve",
"POST",
"Approve or deny a tool call",
request_model=ApproveRequest,
response_model=StatusResponse,
error_codes=[404],
tags=["Chat"],
),
EndpointSpec(
"/v1/api/plan",
"POST",
"Respond to a plan review",
request_model=PlanFeedbackRequest,
response_model=StatusResponse,
error_codes=[404],
tags=["Chat"],
),
EndpointSpec(
"/v1/api/command",
"POST",
"Execute a slash command",
request_model=CommandRequest,
response_model=StatusResponse,
error_codes=[400, 404],
tags=["Chat"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}/cancel",
"POST",
"Cancel the active generation in a workstream",
request_model=CancelRequest,
response_model=StatusResponse,
error_codes=[400, 404],
tags=["Chat"],
),
# --- Streaming ---
EndpointSpec(
"/v1/api/workstreams/{ws_id}/events",
"GET",
"Per-workstream SSE event stream",
description="Opens a Server-Sent Events stream scoped to a single workstream. "
"Returns text/event-stream. See API reference for event types.",
error_codes=[404],
tags=["Streaming"],
),
EndpointSpec(
"/v1/api/events/global",
"GET",
"Global SSE event stream",
description="Server-Sent Events stream for node-level state broadcasts. "
"Emits a node_snapshot event on connect (workstreams, health, aggregate), "
"followed by real-time delta events (ws_state, ws_activity, ws_created, "
"ws_closed, ws_rename, health_changed, aggregate). "
"Pass ?expected_node_id=X for identity verification (returns 409 on mismatch).",
tags=["Streaming"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}/delete",
"POST",
"Permanently delete a saved workstream",
error_codes=[400, 404, 500],
tags=["Workstreams"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}/open",
"POST",
"Load a saved workstream into memory",
error_codes=[400, 404, 500],
tags=["Workstreams"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}/title",
"POST",
"Set workstream title manually",
error_codes=[400, 409],
tags=["Workstreams"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}/refresh-title",
"POST",
"Regenerate workstream title via LLM",
error_codes=[404],
tags=["Workstreams"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}",
"GET",
"Get workstream detail (rehydrates lazily on miss)",
description=(
"Returns the persisted workstream's display fields. If the "
"session isn't currently in memory the manager rehydrates it "
"before responding; ``500`` on rehydrate failure carries a "
"correlation id matching the server log line. Lifted from "
"the coord-only surface in the Stage 2 history/detail verb "
"lift — interactive previously had no detail endpoint."
),
response_model=WorkstreamDetailResponse,
error_codes=[400, 404, 500, 503],
tags=["Workstreams"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}/history",
"GET",
"Read the workstream's reconstructed message history",
description=(
"Returns the tail of the conversation in OpenAI-like message "
"format. Persisted-but-not-loaded workstreams (closed / "
"evicted) serve history without rehydrating. Lifted from "
"the coord-only surface in the Stage 2 history/detail verb "
"lift — interactive previously only exposed history through "
"the SSE replay on ``/events``."
),
response_model=WorkstreamHistoryResponse,
query_params=[
QueryParam(
"limit",
"Max conversation rows to fetch from storage (default 100, max 500).",
schema_type="integer",
default=100,
),
],
error_codes=[400, 404, 500, 503],
tags=["Workstreams"],
),
# --- Workstream attachments ---
EndpointSpec(
"/v1/api/workstreams/{ws_id}/attachments",
"POST",
"Upload a file (multipart/form-data, field 'file') and attach it "
"to the caller's next user turn on this workstream. Validates "
"size, MIME, and UTF-8 for text; magic-byte sniff for images. "
"Ownership failures are masked as 404 so non-owners cannot "
"enumerate workstream existence; a 403 indicates a scope/auth "
"failure from the middleware layer.",
response_model=UploadAttachmentResponse,
error_codes=[400, 403, 404, 409, 413],
tags=["Attachments"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}/attachments",
"GET",
"List the caller's pending (unconsumed) attachments for this "
"workstream. Ownership failures are masked as 404.",
response_model=ListAttachmentsResponse,
error_codes=[403, 404],
tags=["Attachments"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}/attachments/{attachment_id}/content",
"GET",
"Return raw bytes of an attachment with its stored Content-Type. "
"Ownership failures are masked as 404.",
error_codes=[403, 404],
tags=["Attachments"],
),
EndpointSpec(
"/v1/api/workstreams/{ws_id}/attachments/{attachment_id}",
"DELETE",
"Remove a pending attachment (consumed attachments return 404). "
"Ownership failures are also masked as 404.",
error_codes=[403, 404],
tags=["Attachments"],
),
# --- Saved workstreams ---
EndpointSpec(
"/v1/api/workstreams/saved",
"GET",
"List saved workstreams",
response_model=ListSavedWorkstreamsResponse,
tags=["Workstreams"],
),
# --- Skills ---
EndpointSpec(
"/v1/api/skills",
"GET",
"List available skills (summary)",
response_model=ListSkillSummaryResponse,
tags=["Skills"],
),
# --- Models ---
EndpointSpec(
"/v1/api/models",
"GET",
"List available model aliases",
response_model=ListAvailableModelsResponse,
tags=["Models"],
),
# --- Auth ---
EndpointSpec(
"/v1/api/auth/login",
"POST",
"Authenticate with a token",
request_model=AuthLoginRequest,
response_model=AuthLoginResponse,
error_codes=[401],
tags=["Auth"],
),
EndpointSpec(
"/v1/api/auth/setup",
"POST",
"Create first admin user",
request_model=AuthSetupRequest,
response_model=AuthSetupResponse,
error_codes=[400, 409, 503],
tags=["Auth"],
),
EndpointSpec(
"/v1/api/auth/status",
"GET",
"Return auth state",
response_model=AuthStatusResponse,
tags=["Auth"],
),
EndpointSpec(
"/v1/api/auth/logout",
"POST",
"Clear auth cookie",
response_model=StatusResponse,
tags=["Auth"],
),
EndpointSpec(
"/v1/api/auth/oidc/authorize",
"GET",
"Redirect to OIDC provider for SSO login",
response_code=302,
error_codes=[404, 503],
tags=["Auth"],
),
EndpointSpec(
"/v1/api/auth/oidc/callback",
"GET",
"OIDC callback — validates code, provisions user, sets JWT cookie, redirects to app",
response_code=302,
tags=["Auth"],
),
EndpointSpec(
"/v1/api/auth/whoami",
"GET",
"Return authenticated user info and permissions",
response_model=AuthWhoamiResponse,
error_codes=[401],
tags=["Auth"],
),
# --- Memories ---
EndpointSpec(
"/v1/api/memories",
"GET",
"List structured memories",
response_model=ListMemoriesResponse,
query_params=[
QueryParam("type", "Filter by memory type"),
QueryParam("scope", "Filter by scope"),
QueryParam("scope_id", "Filter by scope identifier"),
QueryParam(
"limit", "Max results (default 100, max 200)", schema_type="integer", default=100
),
],
tags=["Memories"],
),
EndpointSpec(
"/v1/api/memories",
"POST",
"Save (upsert) a structured memory",
request_model=SaveMemoryRequest,
response_model=MemoryInfo,
error_codes=[400],
tags=["Memories"],
),
EndpointSpec(
"/v1/api/memories/search",
"POST",
"Search structured memories by query",
request_model=SearchMemoriesRequest,
response_model=ListMemoriesResponse,
tags=["Memories"],
),
EndpointSpec(
"/v1/api/memories/{name}",
"DELETE",
"Delete a structured memory by name and scope",
response_model=StatusResponse,
query_params=[
QueryParam("scope", "Scope (default: global)"),
QueryParam("scope_id", "Scope identifier"),
],
error_codes=[404],
tags=["Memories"],
),
# --- Admin settings ---
EndpointSpec(
"/v1/api/admin/settings",
"GET",
"List interface.* settings with values and sources",
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/settings/{key}",
"PUT",
"Update an interface.* setting",
error_codes=[400, 503],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/settings/{key}",
"POST",
"Update an interface.* setting (alias for PUT)",
error_codes=[400, 503],
tags=["Admin"],
),
# --- Observability ---
EndpointSpec(
"/health",
"GET",
"Server health check",
response_model=HealthResponse,
tags=["Observability"],
),
]
_ALL_MODELS: list[type[BaseModel]] = [
ErrorResponse,
StatusResponse,
AuthLoginRequest,
AuthLoginResponse,
AuthSetupRequest,
AuthSetupResponse,
AuthStatusResponse,
SendRequest,
SendResponse,
DequeueRequest,
ApproveRequest,
PlanFeedbackRequest,
CommandRequest,
CancelRequest,
CreateWorkstreamRequest,
CreateWorkstreamResponse,
CloseWorkstreamRequest,
ListWorkstreamsResponse,
WorkstreamDetailResponse,
WorkstreamHistoryResponse,
DashboardResponse,
ListSavedWorkstreamsResponse,
UploadAttachmentResponse,
ListAttachmentsResponse,
HealthResponse,
SaveMemoryRequest,
MemoryInfo,
ListMemoriesResponse,
SearchMemoriesRequest,
SkillSummary,
ListSkillSummaryResponse,
AvailableModelInfo,
ListAvailableModelsResponse,
]
def build_server_spec() -> dict[str, Any]:
"""Build the OpenAPI spec for the turnstone server."""
return build_openapi(
title="turnstone Server API",
description="Single-node workstream management, chat interaction, and real-time streaming.",
endpoints=SERVER_ENDPOINTS,
models=_ALL_MODELS,
)