mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
fix(close): require non-empty body, restore CloseWorkstreamRequest
Copilot caught three real issues in PR #422 review, all clustered around the close request body contract: 1. The interactive close handler runs with ``supports_close_reason=True``, which calls ``read_json_or_400(request)`` — an empty / non-JSON body returns ``400 {"error": "Invalid JSON body"}``. The previous SDK fix sent NO body via ``json_body=None``, which would 400 against a real server. The mock-transport test silently masked it because the mock answered without inspecting the body. 2. The doc said the body was empty (or ``{}``), with no mention of the optional ``reason`` field, its 512-byte cap, or the credential-redaction guard. 3. The Pydantic schema for close was deleted outright; OpenAPI and SDKs lost their typed shape for the optional ``reason``. Changes: - ``turnstone/api/server_schemas.py``: reintroduce ``CloseWorkstreamRequest`` with a single optional ``reason: str | None = None`` field. Docstring documents the must-be-valid-JSON contract and notes that coord ignores the body (``supports_close_reason=False``). - ``turnstone/api/server_spec.py``: re-import the schema, point the close ``EndpointSpec`` at it via ``request_model=``, restore the ``_ALL_MODELS`` entry. OpenAPI JSON regenerated. - ``turnstone/sdk/server.py``: ``close_workstream`` (sync + async) gains an optional ``reason: str | None = None`` parameter and always sends ``json_body={}`` (or ``{"reason": ...}``) so the body is never empty. Adds a regression test (``test_close_workstream_sends_valid_json_body``) that inspects the raw transport content rather than relying on a path-keyed mock — the kind of check that would have caught this bug pre-merge. - ``sdk/typescript/src/server.ts``: ``closeWorkstream`` gains an optional ``opts.reason`` parameter; reintroduce ``CloseWorkstreamRequest`` interface in ``types.ts`` and re-export from ``index.ts``. - ``docs/api-reference.md``: close section documents the JSON-body requirement, the ``reason`` field, the 512-byte cap, the multibyte-safe behavior, the credential-redaction guard, and the non-string-coercion path. - ``CHANGELOG.md``: amend the 1.5.0 BREAKING block to reflect the schema reintroduction (slim form, ``reason`` optional) instead of the prior "removed outright" claim. 4558 tests passing under ``-m "not live"`` (was 4557 — +1 from the regression test). ruff + mypy clean.
This commit is contained in:
+3
-2
@@ -38,8 +38,9 @@ Three release tracks are maintained:
|
||||
Calls to the old URLs return **404** on 1.5.0+. Bodies on the new
|
||||
URLs no longer carry ``ws_id`` (the path provides it); the
|
||||
``SendRequest`` / ``ApproveRequest`` / ``CancelRequest`` Pydantic
|
||||
schemas drop the field, and ``CloseWorkstreamRequest`` is removed
|
||||
outright (its only field was ``ws_id``).
|
||||
schemas drop the field, and ``CloseWorkstreamRequest`` slims to a
|
||||
single optional ``reason`` field (the body is still required to be
|
||||
valid JSON — send ``{}`` when omitting all fields).
|
||||
|
||||
``/v1/api/plan`` and ``/v1/api/command`` are unaffected and remain
|
||||
body-keyed in this release. The bundled web UI, channel adapters,
|
||||
|
||||
+15
-1
@@ -919,7 +919,21 @@ closed.
|
||||
|-----------|--------|----------|------------------------|
|
||||
| `ws_id` | string | yes | Workstream ID to close |
|
||||
|
||||
The body is empty (or `{}`).
|
||||
**Request body:**
|
||||
|
||||
The body must be valid JSON. If you are not supplying any optional
|
||||
fields, send `{}` — an empty / non-JSON body is rejected with a
|
||||
`400`.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|----------|--------|----------|----------------------------------------------------------|
|
||||
| `reason` | string | no | Optional close reason persisted to `workstream_config`. |
|
||||
|
||||
The `reason` is capped at **512 UTF-8 bytes** (multibyte-safe — the
|
||||
cap holds for CJK and emoji payloads), and the output guard's
|
||||
credential-redaction pass strips secrets before the value is
|
||||
persisted. A non-string `reason` is silently coerced to empty and
|
||||
the close proceeds without writing the field.
|
||||
|
||||
**Response (success):**
|
||||
|
||||
|
||||
@@ -127,6 +127,16 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CloseWorkstreamRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
@@ -2215,6 +2225,26 @@
|
||||
"title": "CreateWorkstreamResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"CloseWorkstreamRequest": {
|
||||
"description": "Body for ``POST /v1/api/workstreams/{ws_id}/close``.\n\nThe body must be valid JSON; send ``{}`` when omitting all\nfields. Pre-1.5 the model also carried a body-keyed ``ws_id``;\n1.5 moved that to the path so the body shrinks to the optional\n``reason``. Coord ignores the body entirely (its close handler\nis wired ``supports_close_reason=False``).",
|
||||
"properties": {
|
||||
"reason": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Optional close reason persisted to ``workstream_config`` for postmortem. Capped at 512 UTF-8 bytes server-side; credential-redaction is applied via the output guard.",
|
||||
"title": "Reason"
|
||||
}
|
||||
},
|
||||
"title": "CloseWorkstreamRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"ListWorkstreamsResponse": {
|
||||
"description": "Response body for ``GET /v1/api/workstreams`` on either kind.\n\nTop-level key is ``workstreams`` regardless of the kind serving\nthe request \u2014 pre-lift coord returned ``{\"coordinators\": [...]}``;\nconvergence lifted both kinds onto the same shape. Coord SDK /\nfrontend consumers branching on ``data.coordinators`` swap to\n``data.workstreams``.",
|
||||
"properties": {
|
||||
|
||||
@@ -84,6 +84,7 @@ export type {
|
||||
CommandRequest,
|
||||
CreateWorkstreamRequest,
|
||||
CreateWorkstreamResponse,
|
||||
CloseWorkstreamRequest,
|
||||
WorkstreamInfo,
|
||||
ListWorkstreamsResponse,
|
||||
DashboardWorkstream,
|
||||
|
||||
@@ -93,11 +93,16 @@ export class TurnstoneServer extends BaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
async closeWorkstream(wsId: string): Promise<StatusResponse> {
|
||||
async closeWorkstream(
|
||||
wsId: string,
|
||||
opts?: { reason?: string },
|
||||
): Promise<StatusResponse> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (opts?.reason !== undefined) body.reason = opts.reason;
|
||||
return this.request(
|
||||
"POST",
|
||||
`/v1/api/workstreams/${encodeURIComponent(wsId)}/close`,
|
||||
{ json: {} },
|
||||
{ json: body },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -161,6 +161,15 @@ export interface CreateWorkstreamResponse {
|
||||
attachment_ids?: string[];
|
||||
}
|
||||
|
||||
export interface CloseWorkstreamRequest {
|
||||
/**
|
||||
* Optional close reason persisted to `workstream_config` for
|
||||
* postmortem. Capped at 512 UTF-8 bytes server-side; credential
|
||||
* redaction is applied via the output guard.
|
||||
*/
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface WorkstreamInfo {
|
||||
// Renamed `id` → `ws_id` and added kind/parent_ws_id/user_id in
|
||||
// the Stage 2 list-verb lift. Pre-1.5 readers branching on
|
||||
|
||||
@@ -107,6 +107,32 @@ async def test_close_workstream():
|
||||
assert resp.status == "ok"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_close_workstream_sends_valid_json_body():
|
||||
"""The interactive close handler reads the body via
|
||||
``read_json_or_400`` (``supports_close_reason=True``), so a missing
|
||||
or non-JSON body 400s. Regression-lock that the SDK never sends
|
||||
an empty body. ``request.json()`` raises ``ValueError`` on empty
|
||||
bytes; this handler asserts the SDK actually transmitted a JSON
|
||||
object."""
|
||||
captured: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["content"] = bytes(request.content)
|
||||
captured["body"] = json.loads(request.content) if request.content else None
|
||||
return httpx.Response(200, json={"status": "ok"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
||||
client = AsyncTurnstoneServer(httpx_client=hc)
|
||||
# Default call (no reason) — body must still be valid JSON.
|
||||
await client.close_workstream("ws1")
|
||||
assert captured["body"] == {}
|
||||
# With reason — field round-trips.
|
||||
await client.close_workstream("ws1", reason="task complete")
|
||||
assert captured["body"] == {"reason": "task complete"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chat interaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -176,6 +176,26 @@ class CreateWorkstreamResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class CloseWorkstreamRequest(BaseModel):
|
||||
"""Body for ``POST /v1/api/workstreams/{ws_id}/close``.
|
||||
|
||||
The body must be valid JSON; send ``{}`` when omitting all
|
||||
fields. Pre-1.5 the model also carried a body-keyed ``ws_id``;
|
||||
1.5 moved that to the path so the body shrinks to the optional
|
||||
``reason``. Coord ignores the body entirely (its close handler
|
||||
is wired ``supports_close_reason=False``).
|
||||
"""
|
||||
|
||||
reason: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optional close reason persisted to ``workstream_config`` "
|
||||
"for postmortem. Capped at 512 UTF-8 bytes server-side; "
|
||||
"credential-redaction is applied via the output guard."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List / dashboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -22,6 +22,7 @@ from turnstone.api.server_schemas import (
|
||||
ApproveRequest,
|
||||
AvailableModelInfo,
|
||||
CancelRequest,
|
||||
CloseWorkstreamRequest,
|
||||
CommandRequest,
|
||||
CreateWorkstreamRequest,
|
||||
CreateWorkstreamResponse,
|
||||
@@ -84,6 +85,7 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
|
||||
"/v1/api/workstreams/{ws_id}/close",
|
||||
"POST",
|
||||
"Close a workstream",
|
||||
request_model=CloseWorkstreamRequest,
|
||||
response_model=StatusResponse,
|
||||
error_codes=[400, 404],
|
||||
tags=["Workstreams"],
|
||||
@@ -447,6 +449,7 @@ _ALL_MODELS: list[type[BaseModel]] = [
|
||||
CancelRequest,
|
||||
CreateWorkstreamRequest,
|
||||
CreateWorkstreamResponse,
|
||||
CloseWorkstreamRequest,
|
||||
ListWorkstreamsResponse,
|
||||
WorkstreamDetailResponse,
|
||||
WorkstreamHistoryResponse,
|
||||
|
||||
@@ -180,10 +180,14 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
response_model=CreateWorkstreamResponse,
|
||||
)
|
||||
|
||||
async def close_workstream(self, ws_id: str) -> StatusResponse:
|
||||
async def close_workstream(self, ws_id: str, *, reason: str | None = None) -> StatusResponse:
|
||||
body: dict[str, Any] = {}
|
||||
if reason is not None:
|
||||
body["reason"] = reason
|
||||
return await self._request(
|
||||
"POST",
|
||||
f"/v1/api/workstreams/{ws_id}/close",
|
||||
json_body=body,
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
@@ -618,8 +622,8 @@ class TurnstoneServer:
|
||||
)
|
||||
)
|
||||
|
||||
def close_workstream(self, ws_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.close_workstream(ws_id))
|
||||
def close_workstream(self, ws_id: str, *, reason: str | None = None) -> StatusResponse:
|
||||
return self._runner.run(self._async.close_workstream(ws_id, reason=reason))
|
||||
|
||||
# -- chat interaction ----------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user