Files
turnstone/turnstone/core/storage/_utils.py
T
Patrick Buckley e010124008 feat(preview): rich preview pane + open_preview tool
Tool results only ever rendered as plain text in the transcript. This
adds the model-driven rich-preview lane every comparable surface has,
in turnstone's developer-tool idiom: a preview pane that opens BESIDE
the conversation, keyboard-operable, sandboxed, never replacing the
transcript that spawned it.

Backend
- New built-in open_preview(target, kind?, title?): resolves an http(s)
  URL, a file path, or attachment:<id> to bytes; classifies into
  web/pdf/image/table/text/markdown (magic bytes > MIME hint >
  extension > UTF-8 fallback, legacy-charset pages transcoded); caps
  size per kind; persists content-addressed with kind="preview" —
  refcounted and GC'd with the workstream, skipped by trajectory
  reconstruction so preview bytes can never materialize onto the wire.
  URL targets gate like web_fetch (network egress); paths/attachments
  run unprompted like read_file.
- New core.web.fetch_with_ssrf_guard: manual redirect walk that
  SSRF-screens every hop BEFORE requesting it (follow_redirects=True
  checked nothing between hops); adopted by both open_preview and
  web_fetch. URL userinfo is stripped before the descriptor or the
  stored bytes see it; <base href> is injected doctype-safely so
  relative assets resolve without quirks mode.
- The preview descriptor rides the tool turn's meta side channel with
  ONE shape on every boundary: the live tool_result SSE event, the
  conversations.meta column, and the /history projection. Cancelled
  batches commit an already-announced preview (blob + meta) instead of
  stranding the open pane on a permanent 404.
- New GET {ws}/attachments/{id}/preview (read scope, same ownership
  gate as /content) serves the STORED type with per-MIME hardening:
  bare CSP sandbox for text/html (renderable, scriptless, opaque
  origin), no CSP for application/pdf (Chromium's viewer refuses
  sandboxed contexts), full default-src 'none' otherwise; filenames
  fold to latin-1-safe ASCII. The console /node proxy now forwards
  CSP/nosniff/disposition/cache-control instead of dropping them.
- History loads exclude preview blobs from the bulk content fetch at
  the query (they were read and discarded on every load).

Frontend
- New "preview" pane type registered in the shared shell (server +
  console): openPaneBeside placement, per-kind renderers — fully
  sandboxed iframe for pages, browser PDF viewer, sortable tables
  (CSV/TSV/JSON, ragged-file safe, 5k-row cap), rendered markdown,
  text — plus back/forward history with arrow keys, reload persistence
  via pane meta, and backoff auto-retry (0.9s..7.2s) bridging the gap
  between the live descriptor and the batch fold that commits its blob.
- Tool results carrying a descriptor render a credential-redacted
  preview chip (the reopen + replay affordance); live results auto-open
  the pane only while the originating pane holds focus.

Docs: docs/tools.md + prompts/tools.md. Tests: policy unit tests, tool
prepare/exec (mocked fetch), serving route + proxy header pass-through,
storage exclusion on both backends, cancel-path commit, JS static
guards; a headless-Chrome harness drives the real module graph (32 DOM
assertions).
2026-07-07 08:20:57 -07:00

1350 lines
56 KiB
Python

"""Shared utilities for storage backends."""
from __future__ import annotations
import base64
import json
import re
from collections import Counter
from typing import TYPE_CHECKING, Any
import sqlalchemy as sa
if TYPE_CHECKING:
from collections.abc import Iterable
from turnstone.core.attachments import AUDIO_MIME_TO_FORMAT, unreadable_placeholder
from turnstone.core.log import get_logger
from turnstone.core.storage._schema import (
conversations,
workstream_attachments,
workstream_config,
workstream_overrides,
workstreams,
)
from turnstone.core.trajectory import (
AttachmentRef,
ContentBlock,
ProviderNative,
Role,
TextBlock,
ToolCall,
Turn,
TurnMeta,
dicts_from_turns,
resolve_attachment_parts,
)
log = get_logger(__name__)
# Client tool-call block types across providers, used to enforce the
# native↔tool_calls mirror (see ``normalize_native_for_save``). Anthropic emits
# ``tool_use``; OpenAI Responses ``function_call``; Google's OpenAI-compat lane
# ``function``. Server-side tool blocks (``server_tool_use`` /
# ``web_search_tool_result`` / ``web_search_call`` / ``*_call`` results) and
# reasoning blocks are deliberately absent: they carry no client ``tool_result`` to
# orphan and must round-trip verbatim.
_CLIENT_TOOL_CALL_BLOCK_TYPES: frozenset[str] = frozenset({"tool_use", "function_call", "function"})
def strip_orphan_client_tool_blocks(blocks: list[Any]) -> list[Any]:
"""Drop client tool-call blocks from a provider-native block list.
Used when an assistant turn carries no ``tool_calls``: a client tool-call block
left in the native lane would replay as an orphan ``tool_use`` / ``function_call``
with no matching ``tool_result`` and be rejected by the API on a same-provider
resume. Reasoning / server-tool / web-search blocks are preserved. Returns a new
list; the input is not mutated.
"""
return [
b
for b in blocks
if not (isinstance(b, dict) and b.get("type") in _CLIENT_TOOL_CALL_BLOCK_TYPES)
]
def _has_tool_calls(tool_calls_json: str | None) -> bool:
"""True when the stored ``tool_calls`` JSON encodes a non-empty list."""
if not tool_calls_json:
return False
try:
parsed = json.loads(tool_calls_json)
except (json.JSONDecodeError, TypeError):
return False
return isinstance(parsed, list) and len(parsed) > 0
def normalize_native_for_save(
role: str | None, provider_data: str | None, tool_calls_json: str | None
) -> str | None:
"""Enforce the native↔tool_calls mirror at the persistence boundary.
A persisted ``assistant`` row must never carry a *client* tool-call block in its
verbatim native lane (``provider_data``) without a matching entry in ``tool_calls``;
otherwise a same-provider resume replays an orphan ``tool_use`` / ``function_call``
with no ``tool_result`` and the provider API rejects the turn (the
truncated-mid-tool_use hole). Enforcing the mirror here — once, for every save path
and both backends — is what lets the orphan-repair pass read ``tool_calls`` alone
(and lets the Anthropic ``pc_tool_ids`` fallback be retired).
When ``tool_calls`` is empty, client tool-call blocks in ``provider_data`` are
orphaned and dropped (reasoning / server-tool / web-search blocks kept). Returns the
(possibly rewritten) ``provider_data`` JSON, ``None`` if nothing survives, or the input
unchanged for non-assistant / well-formed rows.
"""
if role != "assistant" or not provider_data or _has_tool_calls(tool_calls_json):
return provider_data
try:
blocks = json.loads(provider_data)
except (json.JSONDecodeError, TypeError):
return provider_data
if not isinstance(blocks, list):
return provider_data
kept = strip_orphan_client_tool_blocks(blocks)
if len(kept) == len(blocks):
return provider_data
return json.dumps(kept) if kept else None
def wrap_provider_data(provider_data: str | None, producer: str | None) -> str | None:
"""Wrap a bare native-block list in the storage envelope ``{producer, blocks}``.
``producer`` (the provider that generated the turn) lets the lowering layer replay the
native lane verbatim only to its producing provider. Storage-only: the envelope is
unwrapped back to a bare block list by :func:`reconstruct_messages`, so every
``_provider_content`` consumer still sees a plain list. Input that is empty, already
wrapped, unparseable, or has no ``producer`` is returned unchanged (the last keeps the
legacy bare-list shape, which reconstruct dual-reads).
"""
if not provider_data or not producer:
return provider_data
try:
blocks = json.loads(provider_data)
except (json.JSONDecodeError, TypeError):
return provider_data
if isinstance(blocks, dict) and "blocks" in blocks:
return provider_data
return json.dumps({"producer": producer, "blocks": blocks})
def prepare_provider_data_for_save(
role: str | None,
provider_data: str | None,
tool_calls_json: str | None,
producer: str | None,
) -> str | None:
"""Save-boundary preparation of the native lane: enforce the mirror, then wrap.
The single entry point both backends' save paths call:
:func:`normalize_native_for_save` (the native↔tool_calls mirror, on the bare block
list) followed by :func:`wrap_provider_data` (the ``{producer, blocks}`` envelope).
"""
return wrap_provider_data(
normalize_native_for_save(role, provider_data, tool_calls_json), producer
)
def release_attachment_refs(conn: Any, attachment_ids: list[str]) -> None:
"""Decrement each referenced blob's refcount, prune any that reach 0.
The single dialect-agnostic owner of the content-addressed GC decrement —
both backends call this on rewind/retry (``delete_messages_after``) and on
workstream delete; the increment twin is each backend's ``save_attachment``
(INSERT-OR-IGNORE + unconditional ``+1``). Counts duplicate ids in the
input — a turn references an id once, but a batch may span several turns that
each reference the same deduped blob — so the decrement matches the
references actually removed. One UPDATE (a searched CASE maps each id to its
decrement; portable across SQLite/Postgres, unlike a VALUES join) plus one
prune DELETE, rather than a query per distinct id. Caller holds the
connection / transaction.
"""
if not attachment_ids:
return
counts = Counter(attachment_ids)
ids = list(counts)
decrement = sa.case(
*((workstream_attachments.c.attachment_id == aid, n) for aid, n in counts.items()),
else_=0,
)
conn.execute(
sa.update(workstream_attachments)
.where(workstream_attachments.c.attachment_id.in_(ids))
.values(refcount=workstream_attachments.c.refcount - decrement)
)
conn.execute(
sa.delete(workstream_attachments).where(
sa.and_(
workstream_attachments.c.attachment_id.in_(ids),
workstream_attachments.c.refcount <= 0,
)
)
)
def find_orphan_conversations(conn: Any) -> list[dict[str, Any]]:
"""Conversation ws_ids that have no ``workstreams`` row, with row stats.
Orphans come from writers that persisted without a registered workstream:
historically the pre-unification CLI/server paths, and the
delete-during-inflight race (a late tool-result save re-creating rows
after ``delete_workstream``). Read-only; ordered oldest-first. Each
entry carries the attachment-ref count so a purge's refcount release is
visible before it happens.
"""
anti_join = conversations.outerjoin(workstreams, conversations.c.ws_id == workstreams.c.ws_id)
rows = conn.execute(
sa.select(
conversations.c.ws_id,
sa.func.count().label("row_count"),
sa.func.min(conversations.c.timestamp).label("first"),
sa.func.max(conversations.c.timestamp).label("last"),
)
.select_from(anti_join)
.where(workstreams.c.ws_id.is_(None))
.group_by(conversations.c.ws_id)
.order_by(sa.func.min(conversations.c.timestamp))
).fetchall()
# Ref counts in ONE pass over the orphan rows that carry attachments —
# not a query per orphan workstream, so the scan stays proportional to
# orphan ROW count.
ref_counts: dict[str, int] = {}
ref_rows = conn.execute(
sa.select(conversations.c.ws_id, conversations.c.attachments)
.select_from(anti_join)
.where(
sa.and_(
workstreams.c.ws_id.is_(None),
conversations.c.attachments.is_not(None),
)
)
).fetchall()
for ws_id, refs in ref_rows:
ref_counts[ws_id] = ref_counts.get(ws_id, 0) + len(parse_attachment_refs(refs))
return [
{
"ws_id": ws_id,
"rows": int(row_count),
"first": first,
"last": last,
"attachment_refs": ref_counts.get(ws_id, 0),
}
for ws_id, row_count, first, last in rows
]
# IN-list chunk size for the purge statements — mirrors the storage layer's
# existing bulk chunking (SQLite bind-parameter limits).
_PURGE_CHUNK = 500
def purge_orphan_conversations(conn: Any, ws_ids: list[str]) -> dict[str, int]:
"""Delete conversation rows for the *ws_ids* that are STILL orphans.
Orphan-ness is enforced INSIDE the DELETE itself (a correlated
``NOT EXISTS`` against ``workstreams``), and the refcounts to release
come from the DELETE's ``RETURNING`` — so refs are released for exactly
the rows that were deleted. A ws_id registered at any point before the
DELETE statement keeps both its rows AND its refcounts; there is no
pre-count/delete window to underflow. (Needs ``DELETE .. RETURNING``:
PostgreSQL, or SQLite ≥ 3.35.)
Input is de-duplicated and all IN-lists are chunked. ``skipped`` =
distinct input ws_ids not purged (registered before/during the purge, or
no rows). The purged ws_ids' ``workstream_config`` /
``workstream_overrides`` rows are swept. Caller owns commit.
"""
distinct = list(dict.fromkeys(ws_ids))
if not distinct:
return {"workstreams": 0, "rows": 0, "released_refs": 0, "skipped": 0}
ref_ids: list[str] = []
purged_ws: set[str] = set()
rows_deleted = 0
for i in range(0, len(distinct), _PURGE_CHUNK):
chunk = distinct[i : i + _PURGE_CHUNK]
returned = conn.execute(
sa.delete(conversations)
.where(
sa.and_(
conversations.c.ws_id.in_(chunk),
~sa.exists(
sa.select(workstreams.c.ws_id).where(
workstreams.c.ws_id == conversations.c.ws_id
)
),
)
)
.returning(conversations.c.ws_id, conversations.c.attachments)
).fetchall()
for ws_id, refs in returned:
purged_ws.add(ws_id)
rows_deleted += 1
if refs:
ref_ids.extend(parse_attachment_refs(refs))
release_attachment_refs(conn, ref_ids)
swept = sorted(purged_ws)
for i in range(0, len(swept), _PURGE_CHUNK):
chunk = swept[i : i + _PURGE_CHUNK]
conn.execute(sa.delete(workstream_config).where(workstream_config.c.ws_id.in_(chunk)))
conn.execute(sa.delete(workstream_overrides).where(workstream_overrides.c.ws_id.in_(chunk)))
return {
"workstreams": len(purged_ws),
"rows": rows_deleted,
"released_refs": len(ref_ids),
"skipped": len(distinct) - len(purged_ws),
}
def attachment_to_content_part(att: dict[str, Any]) -> dict[str, Any] | None:
"""Convert a stored attachment row into an OpenAI-style content part.
Returns ``None`` if the attachment's ``kind`` / ``content`` cannot be
turned into a content part (logged but non-fatal so history still renders).
"""
kind = att.get("kind")
raw = att.get("content")
mime = att.get("mime_type") or "application/octet-stream"
if kind == "image" and isinstance(raw, bytes):
from turnstone.core.images import normalize_image_orientation
# Bake EXIF orientation into the pixels — the model's image decoder, like
# Pillow, ignores the orientation tag, so a phone photo would otherwise be
# perceived sideways. Unrotated images pass through untouched.
b64 = base64.b64encode(normalize_image_orientation(raw)).decode("ascii")
return {
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"},
}
if kind == "text" and isinstance(raw, bytes):
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
log.warning(
"attachment id=%s stored as text but not valid UTF-8",
att.get("attachment_id"),
)
return unreadable_placeholder(att.get("filename") or "")
return {
"type": "document",
"document": {
"name": att.get("filename") or "",
"media_type": mime,
"data": text,
},
}
if kind == "pdf" and isinstance(raw, bytes):
# PDF rides as a ``document`` part discriminated by media_type:
# base64 bytes (vs. a text doc's utf-8 ``data``). Per-provider
# translators branch on ``application/pdf`` (Phase 2); the client-side
# fallback for non-PDF models lands in Phase 3.
b64 = base64.b64encode(raw).decode("ascii")
return {
"type": "document",
"document": {
"name": att.get("filename") or "",
"media_type": "application/pdf",
"data": b64,
},
}
if kind == "audio" and isinstance(raw, bytes):
# OpenAI-style ``input_audio`` part — passes through the openai-compat
# lane untouched (omni models); other lanes translate / fall back in
# Phase 2/3. ``format`` is the bare codec token derived from the MIME.
b64 = base64.b64encode(raw).decode("ascii")
fmt = AUDIO_MIME_TO_FORMAT.get(mime) or (mime.split("/", 1)[-1] if "/" in mime else "wav")
return {
"type": "input_audio",
"input_audio": {"data": b64, "format": fmt},
}
return None
def parse_attachment_refs(raw: str | None) -> list[str]:
"""Decode a ``conversations.attachments`` ref-list column into id strings.
The column stores a JSON array of content-addressed ``attachment_id``s in
turn order (NULL / empty for turns with no attachments). Malformed or
non-list JSON decodes to an empty list (defensive — a corrupt column must
never crash a history load); non-string elements are dropped.
"""
if not raw:
return []
try:
parsed = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return []
if not isinstance(parsed, list):
return []
return [str(x) for x in parsed if isinstance(x, str) and x]
def build_attachments_by_msg(
attachment_refs: dict[int, list[str]],
rows_by_id: dict[str, dict[str, Any]],
) -> dict[int, list[dict[str, Any]]]:
"""Assemble the ``reconstruct_messages`` attachment map from ref-lists.
``attachment_refs`` maps a conversations row id to its ordered list of
content-addressed ids (from :func:`parse_attachment_refs`); ``rows_by_id``
maps an attachment id to its resolved blob row (incl. ``content`` bytes).
Returns ``{row_id: [att_row, ...]}`` preserving ref-list order, skipping
ids whose blob is missing (pruned / never written). Empty lists are
omitted so the caller can pass ``result or None`` unchanged.
"""
grouped: dict[int, list[dict[str, Any]]] = {}
for mid, ids in attachment_refs.items():
resolved = [rows_by_id[aid] for aid in ids if aid in rows_by_id]
if resolved:
grouped[mid] = resolved
return grouped
def _reconstruct_attachment_refs(
attachments_by_msg: dict[int, list[dict[str, Any]]] | None,
row_id: int | None,
) -> tuple[list[AttachmentRef], list[dict[str, Any]]]:
"""Build ``(attachment_refs, attachments_meta)`` for a row from its ref-list.
``attachments_by_msg`` maps a conversations row id to the ordered list of
content-addressed attachment rows referenced by that row's ``attachments``
column. Returns the by-reference content blocks (:class:`AttachmentRef`, in
ref-list order — bytes resolve at the consumer, never carried in the Turn)
and the display-oriented ``_attachments_meta`` siblings (kind / filename /
mime_type) so history replay keeps filenames available (e.g. image pills).
Shared by the user- and tool-row reconstruction.
"""
refs: list[AttachmentRef] = []
meta: list[dict[str, Any]] = []
if not attachments_by_msg or row_id is None:
return refs, meta
for att in attachments_by_msg.get(row_id, []):
# AttachmentRef.kind is the by-reference placeholder kind: the stored
# blob kind verbatim for image / pdf / audio, else 'document' for a
# stored 'text' blob (so the placeholder type can't collide with a real
# text content part on the dict round-trip). The blob kind drives the
# actual resolution; preserving pdf / audio here keeps the reloaded
# placeholder type ({type:pdf} / {type:audio}) consistent with the live
# injection path, which already emits those.
kind_str = str(att.get("kind") or "")
if kind_str == "preview":
# Preview-pane blobs ride the ref-list ONLY for refcount GC and
# the serving-route ownership gate — they are frontend content,
# addressed by the tool turn's meta descriptor, and must never
# become a content block a wire materialization could inline.
continue
ref_kind = kind_str if kind_str in ("image", "pdf", "audio") else "document"
refs.append(
AttachmentRef(
attachment_id=str(att.get("attachment_id") or ""),
kind=ref_kind,
)
)
meta.append(
{
"kind": str(att.get("kind") or ""),
"filename": str(att.get("filename") or ""),
"mime_type": str(att.get("mime_type") or ""),
# Doc-budget proxy for the by-reference placeholder (which carries
# no inline bytes): the token estimator reads this so a reloaded
# document turn isn't counted as ~free. See ``_msg_text_chars``.
"size_bytes": int(att.get("size_bytes") or 0),
}
)
return refs, meta
# ---------------------------------------------------------------------------
# Search-term normalization
# ---------------------------------------------------------------------------
# Composition can hand a multi-KB pasted user message to ILIKE-based search;
# without a cap, every distinct token would emit one unindexable predicate
# per scope-fanned query, producing hundreds of seq-scan clauses on a single
# rebuild. Cap + dedupe + length filter keeps the SQL bounded.
_MAX_SEARCH_TERMS = 16
_MIN_TERM_LEN = 2
# Streaming tokenizer — finditer doesn't allocate a full list up front,
# so a multi-KB pasted query stops being scanned the moment the cap is
# hit instead of after splitting every token.
_TOKEN_RE = re.compile(r"\S+")
def normalize_search_terms(query: str) -> list[str]:
"""De-dupe (case-insensitive), drop short tokens, and cap at MAX terms."""
seen: set[str] = set()
terms: list[str] = []
for match in _TOKEN_RE.finditer(query):
raw = match.group()
lowered = raw.lower()
if len(lowered) < _MIN_TERM_LEN or lowered in seen:
continue
seen.add(lowered)
terms.append(raw)
if len(terms) >= _MAX_SEARCH_TERMS:
break
return terms
# ---------------------------------------------------------------------------
# Text sanitization
# ---------------------------------------------------------------------------
def sanitize_text(value: str | None) -> str | None:
"""Strip NUL bytes that PostgreSQL text fields cannot store.
SQLite tolerates NUL in TEXT but they cause downstream issues (API
payloads, web UI rendering), so both backends use this.
"""
if value and "\x00" in value:
return value.replace("\x00", "")
return value
# ---------------------------------------------------------------------------
# SQL LIKE escaping
# ---------------------------------------------------------------------------
# The escape character paired with :func:`escape_like`. Callers MUST
# pass ``escape=LIKE_ESCAPE`` to SQLAlchemy's ``.like()`` — without
# that kwarg, ``.like()`` uses no escape character at all and the
# ``\%`` / ``\_`` sequences produced by :func:`escape_like` would be
# interpreted as a literal backslash followed by a wildcard. ``\``
# is the SQL standard escape character and works identically on SQLite
# and PostgreSQL when passed explicitly.
LIKE_ESCAPE = "\\"
def escape_like(value: str) -> str:
"""Escape ``%`` and ``_`` (and the escape character itself) so the
string can be safely embedded in a SQL ``LIKE`` pattern.
Pair with ``column.like(escape_like(prefix) + "%", escape=LIKE_ESCAPE)``
to do a true prefix match against caller-supplied input. Without
this, untrusted text containing ``%`` or ``_`` is interpreted as a
wildcard — e.g. a model-supplied watch name of ``"%"`` would match
every row in the queried partition.
"""
return (
value.replace(LIKE_ESCAPE, LIKE_ESCAPE * 2)
.replace("%", LIKE_ESCAPE + "%")
.replace("_", LIKE_ESCAPE + "_")
)
# ---------------------------------------------------------------------------
# Row helper
# ---------------------------------------------------------------------------
def row_to_dict(row: Any, *bool_fields: str) -> dict[str, Any]:
"""Convert a SQLAlchemy row to a dict, casting named fields to bool."""
d = dict(row._mapping)
for key in bool_fields:
if key in d:
d[key] = bool(d[key])
return d
def split_perms(value: str | None) -> set[str]:
"""Split the comma-separated ``roles.permissions`` column into a set."""
if not value:
return set()
return {p.strip() for p in value.split(",") if p.strip()}
# ---------------------------------------------------------------------------
# Field allowlists for governance update methods
# ---------------------------------------------------------------------------
ROLE_MUTABLE = frozenset({"display_name", "permissions"})
ORG_MUTABLE = frozenset({"display_name", "settings"})
POLICY_MUTABLE = frozenset({"name", "tool_pattern", "action", "priority", "enabled"})
SKILL_MUTABLE = frozenset(
{
"name",
"content",
"category",
"variables",
"is_default",
"description",
"tags",
"source_url",
"version",
"author",
"activation",
"token_estimate",
"model",
"auto_approve",
"temperature",
"reasoning_effort",
"max_tokens",
"token_budget",
"agent_max_turns",
"notify_on_complete",
"enabled",
"allowed_tools",
"license",
"compatibility",
"scan_version",
"risk_level",
"scan_report",
"priority",
"kind",
# SKILL.md spec uplift (migration 056)
"paths",
"hidden_from_menu",
"arguments",
"argument_hint",
}
)
# ``oauth_client_secret_ct`` is intentionally absent from this set. It has
# its own dedicated writer (``StorageBackend.set_mcp_oauth_client_secret_ct``)
# so the encrypt/None-to-clear semantics — owned by
# :class:`turnstone.core.mcp_crypto.MCPTokenStore` — live in one place and
# the generic ``update_mcp_server`` cannot accept a raw ciphertext blob.
MCP_SERVER_MUTABLE = frozenset(
{
"name",
"transport",
"command",
"args",
"url",
"headers",
"env",
"auto_approve",
"enabled",
"registry_name",
"registry_version",
"registry_meta",
"auth_type",
"oauth_client_id",
"oauth_scopes",
"oauth_audience",
"oauth_registration_mode",
"oauth_authorization_server_url",
"oauth_as_issuer_cached",
}
)
MODEL_DEFINITION_MUTABLE = frozenset(
{
"alias",
"model",
"provider",
"base_url",
"api_key",
"context_window",
"capabilities",
"enabled",
"temperature",
"max_tokens",
"reasoning_effort",
"surface_persisted_reasoning",
"replay_reasoning_to_model",
}
)
PROJECT_MUTABLE = frozenset({"name", "visibility", "state", "parent_project_id"})
PROMPT_POLICY_MUTABLE = frozenset({"name", "content", "tool_gate", "priority", "enabled"})
# ``name`` (the slug create requests reference) is deliberately immutable —
# edit display_name instead. Workstream snapshots are self-contained so a
# rename wouldn't break them, but stable slugs keep audit rows and operator
# muscle memory honest.
PERSONA_MUTABLE = frozenset(
{
"display_name",
"description",
"base_prompt",
"tool_allowlist",
"mcp_enabled",
"memory_enabled",
"applies_to_kinds",
"is_default",
"enabled",
}
)
PERSONA_KINDS = frozenset({"interactive", "coordinator"})
def persona_row_to_dict(row: Any) -> dict[str, Any]:
"""Convert a personas row to the Python-typed dict shape the Protocol
documents: JSON columns parsed, 0/1 columns as bool. ``tool_allowlist``
keeps its tri-state — None (unrestricted) vs [] (hard empty) vs [names].
Raises ValueError on a corrupt row: a malformed allowlist or kinds
column must fail loudly (mirroring ``snapshot_from_config``), never
decode into a garbage envelope or mask a broken invariant.
"""
d = row_to_dict(row, "mcp_enabled", "memory_enabled", "is_default", "enabled")
def _load_json(column: str, raw_value: Any) -> Any:
# Re-raise parser errors with the persona named — a bare
# JSONDecodeError message doesn't say WHICH row is corrupt.
try:
return json.loads(raw_value)
except ValueError as exc:
raise ValueError(f"corrupt {column} on persona {d.get('persona_id')!r}: {exc}") from exc
raw = d.get("tool_allowlist")
if raw is None:
d["tool_allowlist"] = None
else:
tools = _load_json("tool_allowlist", raw)
if not isinstance(tools, list) or not all(isinstance(t, str) for t in tools):
raise ValueError(f"corrupt tool_allowlist on persona {d.get('persona_id')!r}")
d["tool_allowlist"] = tools
kinds_raw = d.get("applies_to_kinds")
kinds = _load_json("applies_to_kinds", kinds_raw) if kinds_raw else None
if not isinstance(kinds, list) or not kinds or not all(isinstance(k, str) for k in kinds):
raise ValueError(f"corrupt applies_to_kinds on persona {d.get('persona_id')!r}")
d["applies_to_kinds"] = kinds
return d
# Storage-layer size bounds for operator-authored persona fields. The
# console route truncates its inputs to the same shape, but the storage
# edge is the layer every future ingress (SDK-direct, admin CLI) inherits —
# reject rather than silently truncate here.
PERSONA_FIELD_CAPS: dict[str, int] = {
"display_name": 128,
"description": 1024,
"base_prompt": 32768,
}
PERSONA_ALLOWLIST_MAX_ENTRIES = 512
PERSONA_ALLOWLIST_MAX_NAME_LEN = 256
def serialize_persona_fields(fields: dict[str, Any]) -> dict[str, Any]:
"""Validate + serialize Python-typed persona fields to column values.
Shared by both backends so the tri-state allowlist encoding, the kinds
validation, and the size bounds can't drift between them. Raises
ValueError on malformed or oversized input; unknown keys pass through
(callers filter to the mutable set first where that matters).
"""
out = dict(fields)
for key, cap in PERSONA_FIELD_CAPS.items():
val = out.get(key)
if val is not None and key in out and len(str(val)) > cap:
raise ValueError(f"{key} exceeds {cap} characters")
# An empty inline prompt is not a source: normalise "" to NULL so the
# storage CHECK (base_prompt OR base_prompt_file) and the coalesce
# resolution (base_prompt ?? file) agree on what "unset" means.
bp = out.get("base_prompt")
if "base_prompt" in out and isinstance(bp, str) and not bp.strip():
out["base_prompt"] = None
if "applies_to_kinds" in out:
kinds = out["applies_to_kinds"]
if not isinstance(kinds, list) or not kinds or not set(kinds) <= PERSONA_KINDS:
raise ValueError(
f"applies_to_kinds must be a non-empty subset of {sorted(PERSONA_KINDS)}"
)
out["applies_to_kinds"] = json.dumps(kinds)
if "tool_allowlist" in out and out["tool_allowlist"] is not None:
tools = out["tool_allowlist"]
if not isinstance(tools, list) or not all(isinstance(t, str) for t in tools):
raise ValueError("tool_allowlist must be None or a list of tool names")
if len(tools) > PERSONA_ALLOWLIST_MAX_ENTRIES:
raise ValueError(f"tool_allowlist exceeds {PERSONA_ALLOWLIST_MAX_ENTRIES} entries")
if any(len(t) > PERSONA_ALLOWLIST_MAX_NAME_LEN for t in tools):
raise ValueError(
f"tool_allowlist entries are capped at {PERSONA_ALLOWLIST_MAX_NAME_LEN} chars"
)
out["tool_allowlist"] = json.dumps(tools)
for key in ("mcp_enabled", "memory_enabled", "is_default", "enabled"):
if key in out:
out[key] = 1 if out[key] else 0
return out
def validate_and_clear_default_persona(
conn: Any,
personas_table: Any,
*,
persona_id: str,
kinds: list[str],
enabled: Any,
now: str,
) -> None:
"""Enforce the default-persona invariants and demote the incumbent,
inside the caller's transaction.
Shared by both backends (fully dialect-neutral) so the invariants —
exactly one default per kind, single-kind, enabled — cannot drift.
A corrupt incumbent row raises rather than being skipped: silently
not-demoting it would commit two defaults, the exact state this
helper exists to prevent. Concurrency: the PostgreSQL backend
serializes promotions with an advisory xact lock before calling this;
``assert_single_default_persona`` runs post-promote as the backstop.
"""
if not isinstance(kinds, list) or len(kinds) != 1:
raise ValueError("a default persona must apply to exactly one kind")
if not enabled:
raise ValueError("a disabled persona cannot be the default")
kind = kinds[0]
others = conn.execute(
sa.select(personas_table.c.persona_id, personas_table.c.applies_to_kinds).where(
sa.and_(
personas_table.c.is_default == 1,
personas_table.c.persona_id != persona_id,
)
)
).fetchall()
for oid, okinds_raw in others:
okinds = json.loads(okinds_raw) if okinds_raw else None
if not isinstance(okinds, list):
raise ValueError(f"corrupt applies_to_kinds on persona {oid!r}")
if kind in okinds:
conn.execute(
sa.update(personas_table)
.where(personas_table.c.persona_id == oid)
.values(is_default=0, updated=now)
)
def assert_single_default_persona(conn: Any, personas_table: Any, kind: str) -> None:
"""Post-promote backstop: raise (rolling back the enclosing
transaction) if more than one enabled default applies to *kind* — a
concurrent promotion that slipped past serialization must fail loudly,
never commit a nondeterministic default."""
rows = conn.execute(
sa.select(personas_table.c.persona_id, personas_table.c.applies_to_kinds).where(
personas_table.c.is_default == 1
)
).fetchall()
holders = [oid for oid, kr in rows if kind in (json.loads(kr) if kr else [])]
if len(holders) > 1:
raise ValueError(f"concurrent default-persona change detected for kind {kind!r}; retry")
HEURISTIC_RULE_MUTABLE = frozenset(
{
"name",
"risk_level",
"confidence",
"recommendation",
"tool_pattern",
"arg_patterns",
"intent_template",
"reasoning_template",
"tier",
"priority",
"builtin",
"enabled",
}
)
OUTPUT_GUARD_PATTERN_MUTABLE = frozenset(
{
"name",
"category",
"risk_level",
"pattern",
"pattern_flags",
"flag_name",
"annotation",
"is_credential",
"redact_label",
"priority",
"builtin",
"enabled",
}
)
VERDICT_MUTABLE = frozenset(
{
"user_decision",
"intent_summary",
"risk_level",
"confidence",
"recommendation",
"reasoning",
"evidence",
"tier",
"judge_model",
"latency_ms",
}
)
# ---------------------------------------------------------------------------
# Skill scanning helper
# ---------------------------------------------------------------------------
def scan_skill_content(content: str, allowed_tools: str) -> tuple[str, str, str]:
"""Run the skill scanner and return ``(risk_level, scan_report_json, scanner_version)``.
Uses a lazy import to avoid circular dependencies. Silently returns
empty results on import or scan errors so skill creation is never
blocked by a scanner bug.
"""
try:
from turnstone.core.skill_scanner import SCANNER_VERSION, scan_skill
tools: list[str] | None = None
if allowed_tools and allowed_tools.strip() != "[]":
try:
parsed = json.loads(allowed_tools)
if isinstance(parsed, list):
tools = [str(x) for x in parsed if isinstance(x, str)]
if not tools:
tools = None
except (json.JSONDecodeError, TypeError):
pass # falls back to None (no tool filter)
result = scan_skill(content, tools)
return result.tier, json.dumps(result.to_dict(), ensure_ascii=False), SCANNER_VERSION
except Exception:
log.debug("skill_scanner: scan failed", exc_info=True)
return "", "{}", ""
# ---------------------------------------------------------------------------
# Message reconstruction
# ---------------------------------------------------------------------------
def reconstruct_messages(
rows: list[Any],
ws_id: str,
attachments_by_msg: dict[int, list[dict[str, Any]]] | None = None,
*,
repair: bool = True,
) -> list[dict[str, Any]]:
"""Reconstruct OpenAI message format from stored conversation rows.
Each *row* is an 8-to-11-tuple ``(id, role, content, tool_name,
tool_call_id, provider_data, tool_calls_json, source [, event_id [, is_error
[, meta]]])``, ordered chronologically by row id. ``source`` is rehydrated
as the ``_source`` side channel. (The legacy ``_reminders`` column that used
to ride here was dropped in migration 060 — operator context lives in
first-class ``system`` turns now.) The trailing optional elements —
``event_id`` (migration 059, the per-ws SSE ``Last-Event-ID`` resume cursor →
``_event_id``), ``is_error`` (migration 060, the persisted tool-result error
flag), and ``meta`` (migration 060, an operator-context turn's structured
``_source_meta``) — are handled by the defensive unpack so shorter legacy
fixtures stay valid.
When ``attachments_by_msg`` is provided (keyed by row id, each value an
ordered list of content-addressed attachment rows resolved from the
``conversations.attachments`` ref-list), any ``user`` *or* ``tool`` row
whose id has attachments is rebuilt with multipart list content (text +
image_url/document parts). Tool rows carry persisted vision output
(``read_file`` on an image) this way — they would otherwise reload as the
flattened text alone.
When ``repair`` is True (default) the trailing ``assistant(tool_calls)``
turn is dropped if not all tool_call ids have a matching tool result
(trailing operator-context ``system`` turns are looked through and stripped
with it) — boot-crash recovery so a half-finished turn never replays.
Mid-conversation orphaned tool_calls are *not* filled here; that is the
send-time repair (:func:`turnstone.core.lowering.repair_wire_messages`), the
single place the wire path synthesizes cancellation results. Callers that
consume the messages as LLM context via the session send path
(``session.resume``) get that repair for free; a consumer that bypasses it
(``export``) runs ``repair_wire_messages`` itself. Callers reading for
*display* (the ``/history`` REST endpoint) should pass ``repair=False`` so
the user sees the actual partial state — refreshing during tool execution
otherwise silently drops the trailing turn from the UI.
"""
# Drop compaction checkpoint markers: they are resume-only artifacts (the
# persisted summary that lets a reopened session rehydrate a bounded context,
# see reconstruct_turns_checkpointed), not real conversation turns, so
# /history, export, and search show the true transcript without an injected
# summary.
rows = [r for r in rows if not _is_compaction_marker(r)]
turns = reconstruct_turns(rows, ws_id, attachments_by_msg)
if repair:
turns = recover_trajectory(turns)
# Dict consumers (display, export) want materialized content, so resolve the
# by-reference attachments to inline parts using the blob rows already in
# hand. ``load_message_turns`` is the unresolved canonical path for resume.
dicts = dicts_from_turns(turns)
if attachments_by_msg:
parts_by_id = {
str(att.get("attachment_id") or ""): part
for atts in attachments_by_msg.values()
for att in atts
if (part := attachment_to_content_part(att)) is not None
}
if parts_by_id:
dicts = resolve_attachment_parts(dicts, parts_by_id)
return dicts
def _content_blocks(text: str | None, refs: list[AttachmentRef]) -> tuple[ContentBlock, ...]:
"""Build typed content blocks from a row's text column + attachment refs.
A row with attachments becomes a leading text block plus one
:class:`AttachmentRef` per attachment (``read_file`` vision output, user
uploads — bytes resolve at the consumer); a text-only row is a single text
block, or empty.
"""
if refs:
return (TextBlock(text or ""), *refs)
if text:
return (TextBlock(text),)
return ()
def _native_from_provider_data(
provider_data: str | None, tool_calls_json: str | None
) -> ProviderNative | None:
"""Decode the stored ``provider_data`` lane into a :class:`ProviderNative`.
The storage envelope is ``{producer, blocks}`` (new) or a bare block list
(legacy, no producer). A decode failure or non-list/non-envelope payload
yields ``None`` (the lane is dropped — matching the prior best-effort decode).
Load-side mirror self-heal: when the row carries no ``tool_calls`` (empty or
absent column), any *client* tool-call block in the native lane is an orphan —
a legacy truncated-mid-``tool_use`` row predating the save-time
``normalize_native_for_save`` chokepoint — that would replay on a same-provider
resume with no matching ``tool_result`` (Anthropic 400; Google resurrects it
into ``tool_calls`` via its fidelity lane). Strip those blocks here so every
legacy row heals on read regardless of whether migration 060 tagged it
(reasoning / server-tool / web-search blocks kept). Mirrors
``normalize_native_for_save``'s gate, including its ``None`` when nothing
survives. The healthy (mirror-holds) path is byte-identical to a plain decode.
"""
if not provider_data:
return None
try:
parsed = json.loads(provider_data)
except (json.JSONDecodeError, TypeError):
return None
if isinstance(parsed, dict) and "blocks" in parsed:
producer = parsed.get("producer") or ""
blocks = list(parsed["blocks"])
elif isinstance(parsed, list):
producer = ""
blocks = parsed
else:
return None
if not _has_tool_calls(tool_calls_json):
blocks = strip_orphan_client_tool_blocks(blocks)
if not blocks:
return None
return ProviderNative(producer=producer, blocks=tuple(blocks))
def _source_meta_from_json(meta_json: str | None) -> dict[str, Any] | None:
"""Decode the stored ``meta`` column into an operator-context meta dict.
The persisted twin of ``Turn.meta.extra["source_meta"]`` — a first-class
``system`` turn's structured per-kind fields (e.g. ``watch_triggered``'s
``watch_name`` / ``command`` / poll counters). A decode failure or a
non-object payload yields ``None`` (the meta is dropped — the human-readable
body still lives in ``content``).
"""
if not meta_json:
return None
try:
parsed = json.loads(meta_json)
except (json.JSONDecodeError, TypeError):
return None
return parsed if isinstance(parsed, dict) and parsed else None
def _tool_calls_from_json(tool_calls_json: str | None) -> tuple[ToolCall, ...]:
"""Decode the stored ``tool_calls`` column into typed :class:`ToolCall`s."""
if not tool_calls_json:
return ()
try:
parsed = json.loads(tool_calls_json)
except (json.JSONDecodeError, TypeError):
return ()
if not isinstance(parsed, list):
return ()
return tuple(
ToolCall(
id=tc.get("id", ""),
name=tc.get("function", {}).get("name", ""),
arguments=tc.get("function", {}).get("arguments", ""),
)
for tc in parsed
if isinstance(tc, dict)
)
def reconstruct_turns(
rows: list[Any],
ws_id: str,
attachments_by_msg: dict[int, list[dict[str, Any]]] | None = None,
) -> list[Turn]:
"""Deserialize stored conversation rows into canonical ``Turn``s (pure).
The ``row → Turn`` boundary: one positional unpack of the row tuple, one
``Turn`` per row, no wire-validity correction (the lowering layer owns that
— see :func:`recover_trajectory` for the load-time strip). The legacy
``tool_name`` column (position 3) is unpacked but never used. Unknown roles
are dropped (the roles below are exhaustive for stored conversations);
``developer`` collapses into ``Role.SYSTEM``.
"""
turns: list[Turn] = []
for row in rows:
(row_id, role, content, _tool_name, tc_id, provider_data, tool_calls_json, source) = row[:8]
# event_id (col 9, migration 059) — the per-ws SSE Last-Event-ID cursor;
# is_error (col 10, migration 060); meta (col 11, also migration 060 — the
# operator-context per-kind ``source_meta``) rides last. Defensive
# length checks keep pre-event_id / pre-is_error / pre-meta fixtures valid.
event_id = int(row[8]) if len(row) > 8 and row[8] is not None else None
is_error = bool(row[9]) if len(row) > 9 else False
meta = TurnMeta(event_id=event_id)
raw_meta = _source_meta_from_json(row[10]) if len(row) > 10 else None
if raw_meta is not None:
# The ``meta`` column is role-exclusive: a TOOL row carries the
# typed ``{"effect_status": ..., "preview": ...}`` envelope (each
# key optional); a SYSTEM row carries operator-context
# ``source_meta``. Route so a tool's disposition doesn't land
# under source_meta (and vice versa). Legacy SYSTEM rows (bare
# source_meta dict, no tool keys) fall through.
if role == "tool" and ("effect_status" in raw_meta or "preview" in raw_meta):
if "effect_status" in raw_meta:
meta.extra["effect_status"] = raw_meta["effect_status"]
if "preview" in raw_meta:
meta.extra["preview"] = raw_meta["preview"]
elif role == "user" and "sender" in raw_meta:
# Per-message sender identity (shared-workstream attribution).
# A USER row's meta blob carries only ``{"sender": ...}`` — route
# it to its own key so history replay re-attributes each turn to
# the human who sent it (source_meta rides SYSTEM turns, never
# user turns, so there is no collision).
meta.extra["sender"] = raw_meta["sender"]
else:
meta.extra["source_meta"] = raw_meta
src = str(source) if source else None
if role == "user":
refs, am = _reconstruct_attachment_refs(attachments_by_msg, row_id)
if am:
meta.extra["attachments_meta"] = am
turns.append(Turn(Role.USER, _content_blocks(content, refs), source=src, meta=meta))
elif role == "assistant":
turns.append(
Turn(
Role.ASSISTANT,
_content_blocks(content, []),
tool_calls=_tool_calls_from_json(tool_calls_json),
native=_native_from_provider_data(provider_data, tool_calls_json),
meta=meta,
)
)
elif role == "tool":
trefs, _tmeta = _reconstruct_attachment_refs(attachments_by_msg, row_id)
turns.append(
Turn(
Role.TOOL,
_content_blocks(content, trefs),
tool_call_id=tc_id or "",
is_error=is_error,
meta=meta,
)
)
elif role in ("system", "developer"):
turns.append(Turn(Role.SYSTEM, _content_blocks(content, []), source=src, meta=meta))
return turns
def recover_trajectory(turns: list[Turn]) -> list[Turn]:
"""Strip a trailing incomplete tool-call turn (boot-crash recovery).
The load-time orphan policy: walk back past trailing tool results AND
operator-context system turns (which follow the turn they relate to) to the
turn's assistant head; if its tool_calls are not all answered, drop from the
assistant onward. Mid-conversation orphans are left for the send-time
repair (``lowering.repair_wire_messages``). Returns a new list; the input is
not mutated.
"""
turns = list(turns)
while turns:
tail_tools = 0
idx = len(turns) - 1
while idx >= 0:
tail_role = turns[idx].role
if tail_role is Role.TOOL:
tail_tools += 1
idx -= 1
elif tail_role is Role.SYSTEM:
idx -= 1
else:
break
asst_idx = idx
if asst_idx < 0:
break
asst = turns[asst_idx]
if asst.role is not Role.ASSISTANT or not asst.tool_calls:
break
if tail_tools >= len(asst.tool_calls):
break
del turns[asst_idx:]
return turns
# -- Compaction checkpoints ---------------------------------------------------
#
# When a live session compacts, it swaps its in-memory history for a summary but
# leaves the full transcript in storage. A persisted *checkpoint marker* lets a
# reopened session rehydrate that same bounded view instead of the full history
# (which can exceed the model window — e.g. a long session, or one switched to a
# smaller-context model — and deadlock the first send). The marker is one
# ``assistant`` row tagged ``_source="compaction"`` whose content is the summary
# and whose ``meta`` carries ``{"watermark": <id>}``: every conversation row with
# id <= the watermark was folded into the summary; rows after it are still live.
COMPACTION_SOURCE = "compaction"
COMPACTION_SUMMARY_LABEL = "[Conversation summary]"
# ---------------------------------------------------------------------------
# History-search tenancy scope
# ---------------------------------------------------------------------------
# SQL mirror of ``WorkstreamProjectVisibility`` (core.auth) — THE statement of
# who may see a workstream's rows. A conversation row is hidden from
# ``:scope_user`` only when its workstream links to an EXISTING project whose
# visibility is 'private' and the user is neither the workstream creator, the
# project owner, nor a member. No project link, a dangling link (project row
# deleted), and non-private projects all stay visible — the trusted-team
# default. ``COALESCE(w.user_id, '')`` makes a NULL creator hide (not leak):
# plain ``<>`` would go NULL and drop the row from the hide-subquery. Callers
# never pass an empty ``:scope_user`` (empty scopes to None = unscoped), so
# the COALESCE sentinel cannot collide with a real principal. Portable across
# SQLite and PostgreSQL; expects the conversations table aliased ``c``.
# ``tests/test_search_history_visibility.py`` pins parity with the Python
# predicate — change either side only in lockstep.
HISTORY_VISIBILITY_SCOPE_SQL = (
"AND NOT EXISTS ("
" SELECT 1 FROM workstreams w"
" JOIN projects p ON p.project_id = w.project_id"
" WHERE w.ws_id = c.ws_id"
" AND p.visibility = 'private'"
" AND COALESCE(w.user_id, '') <> :scope_user"
" AND p.owner_id <> :scope_user"
" AND NOT EXISTS ("
" SELECT 1 FROM project_members pm"
" WHERE pm.project_id = w.project_id AND pm.user_id = :scope_user"
" )"
") "
)
# Live-context exclusion for the model-facing recall tool: drop rows of ONE
# workstream (the caller's own) above its compaction checkpoint — those rows
# are the live segment, already in the model's context, and returning them
# wastes result slots on duplicates. Rows at or below the checkpoint are the
# summarized-away past: exactly what recall exists to re-derive.
# ``:excl_after`` = the checkpoint boundary, or ``-1`` for a never-compacted
# workstream — the whole conversation is live then, so the whole workstream
# is excluded. Human-facing surfaces (the /history command) deliberately do
# NOT apply this: a person browsing history has no "context" to duplicate.
HISTORY_CONTEXT_EXCLUSION_SQL = "AND NOT (c.ws_id = :excl_ws AND c.id > :excl_after) "
def _is_compaction_marker(row: Any) -> bool:
"""True when a stored row is a compaction checkpoint marker (``_source`` = row index 7)."""
return len(row) > 7 and row[7] == COMPACTION_SOURCE
def parse_checkpoint_watermark(meta_json: str | None) -> int | None:
"""Parse a compaction marker's ``meta`` JSON into its watermark id.
The single decoder for the checkpoint boundary — shared by the resume
slice (:func:`reconstruct_turns_checkpointed` via
:func:`_compaction_watermark`) and the backends'
``get_compaction_checkpoint``. Returns ``None`` for a marker that
predates the watermark field or whose meta is malformed (callers fall
back to safe behavior: resume loads the full transcript, recall excludes
the whole workstream — never *less* safe than the honest answer)."""
meta = _source_meta_from_json(meta_json)
wm = meta.get("watermark") if meta else None
return wm if isinstance(wm, int) and not isinstance(wm, bool) else None
def _compaction_watermark(row: Any) -> int | None:
"""Read a marker row's checkpoint watermark from its ``meta`` column (row index 10)."""
return parse_checkpoint_watermark(row[10] if len(row) > 10 else None)
def reconstruct_turns_checkpointed(
rows: list[Any],
ws_id: str,
attachments_by_msg: dict[int, list[dict[str, Any]]] | None = None,
*,
checkpoint: bool = True,
) -> list[Turn]:
"""Resume-path reconstruction that honors a persisted compaction checkpoint.
The full-history twin :func:`reconstruct_turns` rehydrates every row — correct
for a session that never compacted, but on a compacted one it reloads the
whole pre-compaction transcript the live session had already summarized away,
which can overflow the context window on reopen and deadlock the first send.
If a compaction marker is present, load only ``[summary] + [rows after its
watermark]`` — the in-memory view the session held when it compacted. The
summarized prefix and any older markers (id <= watermark) are dropped; the
full history stays in storage for ``/history``/export/audit. The marker
reconstructs as an ``assistant`` turn re-tagged ``source="compaction"``
(``reconstruct_turns`` drops ``_source`` for assistant rows), and a
synthetic ``[Conversation summary]`` user label — tagged likewise — is
prepended to match what ``session._compact_messages`` builds in memory
(and to satisfy the leading-user-turn wire contract). The tags keep
provenance-testing consumers (``_find_turn_boundaries``, title gen)
working identically across a reopen.
Falls back to the full reconstruction when there is no marker or its watermark
is absent/corrupt, so every pre-checkpoint session loads exactly as before.
``checkpoint=False`` (export/audit): return the FULL transcript as Turns —
every real row, no watermark slice — but still drop marker rows, since
:func:`reconstruct_turns` does not filter them and a leaked marker would land
as a stray ``assistant`` summary turn mid-history. This is the Turn-path twin
of the marker filter :func:`reconstruct_messages` already applies on the dict
(display) path; resume passes the default ``True``.
"""
marker = max((r for r in rows if _is_compaction_marker(r)), key=lambda r: r[0], default=None)
watermark = _compaction_watermark(marker) if marker is not None else None
if not checkpoint or marker is None or watermark is None:
# Full transcript, marker rows dropped — three cases collapse here:
# ``checkpoint=False`` (export/audit), no marker, and a malformed/legacy
# watermark. ``reconstruct_turns`` does not filter markers, so a corrupt
# marker would otherwise leak its summary as a stray ``assistant`` turn
# mid-history (and a malformed marker must NOT slice — losing real
# messages is worse than reloading the whole transcript).
return reconstruct_turns(
[r for r in rows if not _is_compaction_marker(r)], ws_id, attachments_by_msg
)
# Keep the marker (the summary) plus every non-marker row written after the
# watermark — the preserved tail and everything since. Reconstruct the two
# slices separately so the summary leads regardless of row-id ordering (a
# preserved tail kept verbatim sits at a *lower* id than the marker).
tail = [r for r in rows if r[0] > watermark and not _is_compaction_marker(r)]
label = Turn(Role.USER, _content_blocks(COMPACTION_SUMMARY_LABEL, []), source=COMPACTION_SOURCE)
marker_turns = reconstruct_turns([marker], ws_id, attachments_by_msg)
for t in marker_turns:
t.source = COMPACTION_SOURCE
return [
label,
*marker_turns,
*reconstruct_turns(tail, ws_id, attachments_by_msg),
]
def senders_from_user_meta(metas: Iterable[str | None]) -> list[str]:
"""Distinct, stripped sender ids from USER-row ``meta`` JSON blobs.
A user row's ``meta`` column carries only ``{"sender": ...}`` (the
role-exclusive routing in :func:`reconstruct_turns`); reuses
:func:`_source_meta_from_json` — this file's one safe-decode-tolerate-
garbage helper for this column — so a future change to its tolerance rules
(e.g. a new error type to swallow) doesn't need a second, divergent
implementation kept in sync here. Anything unparsable, non-dict, or
sender-less is skipped so one stray blob cannot poison the participant set.
Sorted for deterministic output across backends.
"""
senders: set[str] = set()
for raw in metas:
parsed = _source_meta_from_json(raw)
sender = parsed.get("sender") if parsed else None
if isinstance(sender, str) and sender.strip():
senders.add(sender.strip())
return sorted(senders)