Files
turnstone/tests
Patrick Buckley a2834349b0 feat(export): export workstream conversations as OpenAI messages JSON
Add a workstream conversation export on three surfaces, all sharing one
serializer (turnstone/core/export.py):

- `turnstone-admin export <ws_id> [--children] [-o FILE|-]` — offline,
  direct-DB. `--children` bundles a coordinator's parent conversation
  plus one JSON per child into a zip (parent.json + children/<id>.json,
  no manifest).
- `GET /v1/api/workstreams/{ws_id}/export` — conversation-only file
  download, mounted on both the node (interactive) and console
  (coordinator) lifespans via `make_export_handler(cfg)`, reusing the
  /history gate ladder (permission_gate, tenant_check, list_kind
  cross-kind isolation) so ownership and isolation come for free.
- Web UI — an "Export conversation" item in the interactive per-tab
  dropdown (scoped to that tab's workstream) and an Export button on the
  coordinator appbar.

Format is OpenAI Chat Completions messages JSON (`{"messages": [...]}`),
built from `sanitize_messages(load_messages(repair=True))`. Persisted
reasoning is surfaced on assistant messages as a flat `reasoning_content`
field (the convention OpenAI-compatible inference servers use) via a
dedicated helper that runs before sanitize strips the internal
_provider_content lane. Attachments ride along as the standard image_url
/ inlined-document content parts.

Lets users get conversations out in a portable interchange format
(backup, fine-tuning datasets, sharing, interop) without lock-in.
Closes #613.

Non-obvious decisions:
- Single format (openai-json); children/zip is CLI-only. The HTTP
  endpoint and web UI are conversation-only, keeping the served surface
  — and its security surface (no child rows read through the coordinator
  handler) — small.
- `reasoning_content`, not the `reasoning` field /history and the
  reasoning-replay path use: export targets the chat-completions
  convention. Documented in export.py to prevent a "consistency fix".
- list_workstreams exposes no cursor, so the child walk passes an
  explicit high limit rather than inheriting the default 100, which
  would silently drop a coordinator's children past 100.
- Interactive export lives in the per-tab menu (interactive is
  per-tab/pane — avoids focused-workstream ambiguity); the coordinator
  is one conversation, so it keeps an appbar button.

Tested: 25 new tests through real storage + handlers (TestClient), incl.
cross-kind isolation 404, misconfig 500, the reasoning + attachment
pipeline, and the coordinator children zip. The shared frontend helper
is verified by a node sandbox harness (re-entrancy guard, button
disable/aria-busy, no-button tab-menu path). Full non-live suite green
(6714 passed); ruff + format + mypy clean; OpenAPI spec updated.
2026-05-29 21:11:16 -07:00
..
2026-04-03 15:36:20 -07:00