Commit Graph

25 Commits

Author SHA1 Message Date
Patrick Buckley 7f0e0406b3 test(approvals): concurrency matrix + suite migration to the cycle model
New regression matrix for the release blockers: cross-approval
independence, lost-wakeup at gate entry, FIFO selector-less
resolution, resolve-all sweep, double-resolution no-op, cards/legacy
view tracking, and the generation-exactness set — stale delivery
rejection, Smart-Approvals origin check, purge keep_origin, the
purge-to-register window eviction, late cross-generation "superseded"
stamping, concurrent smart+human gates, and the pre-delivered-verdict
fast path. Plus sub-agent judge wiring (agent_gate off the main
slot, close() firing all generations) and endpoint tests for cycle
pinning and the Approve+Always race guard.

Gate threads run under one shared mock-patch harness — mock.patch
start/stop of the same target from concurrent threads corrupts the
patcher's restore stack — with a sweep-until-dead teardown so the
conftest leak guard can't trip. Existing suites migrate off the
singleton fields to cycle assertions and the
pending_approval_details wire shape.
2026-07-05 01:57:54 -07:00
Patrick Buckley 77cb76c006 feat(task-agent): recall sub-trajectory + per-agent read isolation
Final chunk of the task_agent modernization: rebuild a finished task
agent's card from /history (reload / reopen while the workstream is in
memory) and isolate each sub-agent's file-read tracking.

Recall: _project_agent_steps projects a sub-agent's trajectory into step
items (FIFO-per-call_id pairing via _iter_agent_tool_results, shared with
_cancel_ledger; output/arguments/count capped); _stash_agent_trajectory
keeps them on the UI in an LRU-bounded store; make_history_handler
attaches them as agent_steps to each task_agent tool_call, and
replayHistory/_replayAgentCard rebuild the collapsed card. In-memory only
(durable persistence deferred); a cold/evicted entry renders the flat
parent row ("not retained"), never a fabricated 0-step card.

Read isolation: _read_files (the blind-overwrite guard's memory) is now
per-sub-agent via the _active_read_files contextvar -- _exec_task copies
the parent's set on spawn and merges the agent's reads back on
completion, so a sibling in the 4-wide pool can't suppress another
agent's guard.

Also: _exec_task now self-reports the task_agent tool_result on every
path (the parent loop only reports error/denied results centrally) --
without it the live card never completed and a failed task recorded
is_error=False in the canonical trajectory. is_error flows from
_tool_error_flags to the recalled step; on_info suppression is per-thread
so a parallel sibling tool's progress isn't dropped.
2026-06-28 04:09:30 -07:00
Patrick Buckley a318265946 fix(fence): bracket trust-fence markers instead of angle-bracket XML
Swap the trust-fence marker shape from <tag_nonce>...</tag_nonce> to
[start tag_nonce]...[end tag_nonce] for both the operator fold (system-reminder)
and the output-guard judge (tool_output). Angle-bracket markup pushed some local
models out of distribution and toward emitting their own turn-structure tokens:
chat templates built around rigid <...>-style structural tokens derail once a
few folded reminders accumulate. The start/end keywords carry no slash (no </ or
[/ closing-tag shape) and read as ordinary text.

Single-source the shape in fence.py (_OPEN_KW/_CLOSE_KW + detection_pattern) so
wrap, neutralize, the forgery/leak detector, and both trust declarations track
one definition. The nonce still rides both boundaries (unforgeable close); the
leak-vs-forgery split and the forge-in / break-out defang are preserved. The
fold is wire-only, so there is no migration; the legacy persisted-envelope
readers keep the old shape.

Add regression tests pinning each trust declaration to fence.wrap's emission so
a future keyword change fails loudly instead of silently desyncing the anchors.
2026-06-26 19:37:01 -07:00
Patrick Buckley cc0fa53077 feat(coordinator): port Regenerate/Edit title to coordinators
Coordinators carry LLM/auto titles like interactive workstreams but had no
way to regenerate or rename them. Port the interactive "Refresh title" (LLM
regenerate) + "Edit title" (manual alias) dropdown actions by lifting the
two handlers — the last shared verbs that weren't yet lifted — and opting
coordinators in.

- session_routes.py: add make_refresh_title_handler / make_set_title_handler
  factories (cfg pattern, mirroring make_close_handler). set_title resolves
  the workstream BEFORE the alias write and 404s when the kind has no
  tenant_check storage gate and the in-memory manager doesn't own it:
  set_workstream_alias is a global, kind-unscoped UPDATE, so this prevents
  an operator renaming a workstream the coord manager doesn't own (e.g. an
  interactive ws via the coord route) and the silent-200 on a bogus id.
- server.py: re-point the interactive bundle to the lifted handlers; drop
  the standalone refresh_workstream_title / set_workstream_title.
- console/server.py: wire refresh_title / set_title into the coord bundle
  (gated by the existing admin.coordinator operator check).
- shell.js: enable titleVerbs on the coordinator pane's tab menu; the
  base-aware lane posts to the console-origin coord routes.

Tests: coord refresh/set-title (regenerate, operator-gate, 404 unknown,
alias store + broadcast, empty, conflict, cross-kind reject); interactive
title tests re-pointed to the lifted handlers for lift-parity; shell.js
coord-menu assertion.
2026-06-17 16:04:18 -07:00
Patrick Buckley c6b2288302 feat(session): consolidate operator-context into first-class system turns
Replace the two operator-context hacks (the <tool_output>/<system-reminder> content envelope and the transient _reminders side-channel) with one persistent {role: system, _source} trajectory turn. Adds supports_mid_conversation_system (claude-opus-4-8): native models take the turn inline; all others fold it into the preceding turn as a nonce-delimited <system-reminder> block declared in the system prompt as the sole trusted marker. Producers (advisories, metacog nudges, user interjections, idle/watch) emit system turns; the envelope/_reminders machinery, escaping round-trip, replay parser, and reminder SSE events are removed. Eager 060 migration un-wraps legacy envelopes. Net -1662 lines.

Known follow-ups from review (unfixed here): (1) the 060 un-wrap heuristic can irreversibly mis-rewrite bare tool rows that resemble the envelope, so do not run the migration until it is tightened; (2) user_interjection turns lost the user-framing/priority preamble (a regression, and a native-path authority-framing concern); (3) native-path wake nudge can emit empty user content.
2026-06-04 11:03:13 -07:00
Patrick Buckley 3070bc4eb5 fix(sse): coerce non-int ui._event_id to None when stamping saves
When the active UI is a MagicMock test double, _ui_event_id() returned
the auto-vivified _event_id mock (getattr finds it, so the None default
never applies). That mock reached the conversations INSERT and failed
to bind ("type 'MagicMock' is not supported"), so save_message raised,
the row was dropped, and tests on the real-storage + mock-UI path broke
(CI: test_session_attachments::test_db_row_stores_text_only).

Coerce a non-int _event_id to None so mock UIs -- and counterless
CLI/eval/placeholder UIs -- stamp NULL (the synthetic-snapshot floor),
matching the documented contract. Production UIs always carry an int,
so behaviour there is unchanged.

Also drop two redundant local `import json` in the new /history
integration tests; the module-level import already covers them.
2026-05-30 04:23:11 -07:00
Patrick Buckley cdc1dbcc1d feat(sse): event-id cursor resume for fresh-connect in-flight tool batches
A fresh browser connect during a parallel tool batch (e.g. several
web_fetch) left completed siblings' tool blocks empty until a manual
refresh: each tool_result SSE event fires the instant a sibling
finishes, but the result messages persist only after the whole batch
returns, so a fresh connect replayed neither the already-fired event
(a fresh connect doesn't replay the ring buffer) nor a /history row.

Route the fresh connect through the same delta replay a reconnect
already uses. Persist the per-ws SSE ring-buffer high-water mark
(_event_id) onto each saved conversation row. /history returns the
committed snapshot up to a resolved-turn-boundary cursor and omits the
trailing executing in-flight turn; the client opens its initial SSE
with that cursor (Last-Event-ID) so the existing replay_ok path
fast-forwards the in-flight turn whole -- tool blocks, results, and
approve/plan prompts all rebuild from the ring buffer.

The cut sits at the last resolved-turn boundary (not max(saved
event_id)), so out-of-order result saves in the post-batch loop can't
move it or strand a sibling. Gated on buffer-liveness (can_replay_from):
reloaded / evicted / awaiting-approval cases keep the in-flight turn in
/history and return a null cursor, falling back to the synthetic
snapshot floor -- preserving the existing in-flight render and never
leaving a turn unrenderable.

- Migration 059: nullable event_id BIGINT on conversations + a
  (ws_id, event_id) index (keeps the cold-open high-water reseed a seek).
- save_message(event_id=) across the storage wrapper / protocol /
  sqlite / postgres backends; get_max_event_id; reconstruct_messages
  surfaces the _event_id side-channel.
- SessionUIBase: reseed _event_id from storage on construction (so the
  id space stays monotonic across restarts); can_replay_from() gate.
- make_history_handler: _resume_cursor_and_trim() + cursor in the
  response (WorkstreamHistoryResponse.cursor). The shared projection,
  export, and coord-rebuild paths are untouched.
- app.js: seed the resume cursor on the initial-connect path only, and
  gate the last_event_id param on != null so a cursor of 0 (a brand-new
  workstream's first-turn boundary) is not dropped.

Tests: helper, storage round-trip, and seed unit tests; two
make_history_handler integration tests (cursor + orphan-trim when
replayable, null cursor + orphan kept when not); app.js static guards.
Migration applies up and down on SQLite.
2026-05-30 04:23:11 -07:00
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
Patrick Buckley 7f7a762acd fix(history): gate /history pending flag on live awaiting-approval
In-flight tool calls did not render when a browser connected fresh to an
in-progress workstream mid-tool-execution; they only reappeared after the
SSE dropped and reconnected.

`project_history_messages` marked the trailing tool-call turn `pending`
from orphan-detection (a tool_call with no result) as a proxy for
"awaiting approval". But an orphan that is *executing* (already approved,
running) is orphan-but-not-awaiting. The renderer skips `pending` turns
because the SSE replay re-emits the interactive approve_request prompt
instead — and during execution `_pending_approval` is None, so nothing
re-emits. The tool call rendered from neither source on a fresh connect,
recovering only on reconnect (ring-buffer replay carries the
tool_info / tool_result events).

Regression from the REST-first history convergence (0ad1ab7f), inherited
by the wire-shape unification (#596): both swapped the `pending` predicate
from the live `_pending_approval` signal to storage orphan-detection,
which diverge exactly during tool execution.

Thread the live awaiting-approval signal from `make_history_handler` into
`project_history_messages` (new `awaiting_approval` param) and gate the
pending mark on it, re-syncing `pending` with the same `_pending_approval`
signal that drives the SSE prompt re-emit. A storage-only / closed ws has
no live session → never pending → trailing orphans render as historical.
Asserted as `dict` to match the detail handler's MagicMock-safe guard.

Adds a projection-level gate test and two handler boundary tests
(execution → renders, awaiting approval → pending). The existing
partial-trailing-turn test now asserts the turn RENDERS, not just that the
row survives — the parity gap that let this regression through.
2026-05-29 17:27:44 -07:00
Patrick Buckley 5bd7af6b73 feat(session): path-key /rewind + /retry into shared verb handlers (#549)
Lift the conversation-modifying /rewind and /retry verbs out of the body-keyed POST /v1/api/command into path-keyed POST /v1/api/workstreams/{ws_id}/rewind ({turns:N}) and /retry, as make_rewind_handler/make_retry_handler in SharedSessionVerbHandlers (template: make_close_handler/make_cancel_handler), wired on both interactive and coordinator kinds. Closes the last unlifted conversation-modifying surface — coordinator workstreams gain rewind/retry where they had none — and removes the surviving exception to the post-#422 path-keyed URL convention.

Handler shape: auth gate (coord -> admin.coordinator via permission_gate; interactive -> conversation.modify via accepted_permissions) -> busy-gate -> session.rewind(n)/retry() -> always emit clear_ui (incl. rewind-to-zero, carries #503) -> audit (conversation.rewind/retry on both kinds). Retry re-dispatch reuses the shared session_worker.send via a per-kind dispatch_retry closure (hard-reject on busy), not a third hand-rolled thread.

The web /command handler now rejects /rewind+/retry with a pointer to the path-keyed endpoint (BREAKING; 1.6.0aN-tolerant); session.handle_command's branches stay for the terminal CLI. auth.py adds the verbs to both write suffix-sets; Python + TS SDKs, OpenAPI (RewindRequest + server/console specs), the /route/ proxy mounts + audit actions, and coordinator_client all gain them.

Interactive frontend (app.js): the 3 /command POST sites + the hand-typed-slash reroute now hit the path-keyed endpoints; the bare .msg.user rewind selector is kept (matches the server's _find_turn_boundaries, which counts system-nudge user turns). The coordinator frontend rewind UX lands in a follow-up commit (browser-verified).

Tests: route-walk mount/order, /route/ audit rows, required_scope, OpenAPI catalog, SDK body-inspection, and HTTP-level handler behavior (busy-gate, turns validation, clear_ui emit, retry dispatch, audit invocation + swallow).
2026-05-28 21:02:51 -07:00
Patrick Buckley ee8dc7c1c3 refactor(history): project the /history wire shape server-side
Collapse the three hand-synced "raw storage -> render shape" projections
into one server-side projection. The projection previously lived in a
test-only `_build_history` (SSE-era reference impl), a client-side JS
normaliser (`history_normalize.js`, the transitional bridge), and coord's
inline `init()` handling -- drifting silently with no parity test.

Add `project_history_messages` to `history_decoration.py` and run it as the
final step of the `make_history_handler` pipeline (load_messages -> decorate
-> extract_reasoning -> project), so `GET /history` emits the canonical
render shape directly: flat tool_calls (with verdict / output_assessment),
top-level source / reminders / attachments, collapsed multipart content,
derived denied / is_error / pending, reasoning, and advisories. Interactive
`replayHistory` now consumes the payload verbatim.

Close two gaps the JS bridge deferred:
- list-content <tool_output> advisory extraction (decorate handles only
  string content; the projection extracts list-carrier advisories, then
  joins remaining text parts to the string the renderers require);
- orphan->pending marks ONLY the last orphan tool-call turn, so a
  mid-conversation cancelled tool still renders instead of vanishing.

Delete `history_normalize.js` (+ its <script> tag and node test) and the
test-only `_build_history` (+ orphaned imports); retarget its direct tests
onto the projection helpers. Update the WorkstreamHistoryResponse
description and the Web UI Resilience architecture note to the projected
shape.

Coord's `init()` still reads the raw side-channels; migrating it to the
projected shape is the next commit, browser-verified separately.

Refs #549.
2026-05-28 17:14:50 -07:00
Patrick Buckley 33865ca9d2 fix(reasoning): apply full-stack review findings
Multi-stage /review on the full Phase 1+2+3+4 stack surfaced 9 findings
(0 critical, 3 major, 5 minor, 1 nit, 1 uncertain).  All applied.

Major

* perf-1 (session_routes.py:2402): make_history_handler ran sync
  storage.load_workstream_config inside async def history on the cold-
  workstream path, blocking the event loop on every dashboard /history
  request for non-resident workstreams.  Every other storage call in
  the same handler correctly used asyncio.to_thread.  Wrap the sync
  call in asyncio.to_thread (preserving the existing try/except so a
  DB failure still degrades to the conservative-default branch instead
  of bubbling out).

* q-2 (test_reasoning_audit_log_discipline.py): the security-sensitive
  test (reasoning text never lands at INFO+ severity) only covered the
  4 Phase 1 surfaces.  Phase 2 added the strip predicate in
  AnthropicProvider._convert_messages and Phase 3 added 3 more code
  paths that touch reasoning text — none guarded.  Added 4 parallel
  tests using the existing capture-and-walk infrastructure:
  OpenAIResponsesProvider.extract_reasoning_text,
  OpenAIChatCompletionsProvider.extract_reasoning_text,
  ChatSession._stream_response (drives the synth-block stamp via a
  fake reasoning-emitting stream), AnthropicProvider._convert_messages
  with replay_reasoning_to_model=False (drives the Phase 2 strip
  predicate).

* q-1 (model_registry.py:42): the persist_reasoning flag name implied
  storage-control but actually gates UI rehydration only — operators
  flipping it could reasonably expect "stop persisting reasoning" but
  storage of reasoning bytes happens in provider_data regardless.
  Renamed everywhere to surface_persisted_reasoning: ModelConfig
  field, migration 052 column (renaming in-place since 052 is not yet
  on main), schema, MODEL_DEFINITION_MUTABLE allowlist, _postgresql.py
  + _sqlite.py CRUD impls, _protocol.py create_model_definition
  signature, 3 console_schemas Pydantic models, console/server.py
  admin POST + PUT, model_registry row mapper, history_decoration.py
  helper parameter, server.py _build_history local var,
  session_routes.py make_history_handler local var, sdk/events.py
  HistoryEvent docstring, admin.js form id + override pill label,
  index.html form input id + UI label + tooltip, coordinator.js (none
  needed), and every test that referenced the old field name.  The
  admin tooltip now reads "Storage of reasoning bytes is unaffected
  by this flag — they ride in provider_data regardless" so the
  decoupling stays explicit at the operator surface.

Minor

* bug-1 (history_decoration.py:336): dispatcher discriminated on
  provider_content[0]["type"] only.  Anthropic's redacted_thinking
  blocks (sealed by the safety system) can appear before, after, or
  interleaved with regular thinking blocks per the API docs.  When a
  redacted block lands first, the dispatcher returned "" and the UI
  silently lost the surrounding thinking text.  Registered
  "redacted_thinking" as a second key in _BLOCK_TYPE_PROVIDER_FACTORY
  pointing at the same AnthropicProvider factory — the existing
  extractor's type=="thinking" filter already correctly skips redacted
  blocks while walking the full list.  Regression test added.

* q-3 (_protocol.py:155): replay_reasoning_to_model defaults split
  across 9 sites — operator-side defaults to False (matches DB
  server_default), provider-API defaults to True (back-compat with
  direct callers).  Original "pick False everywhere" fix would have
  silently flipped behaviour for any direct provider caller.  Instead
  documented the intentional bifurcation in the Protocol's
  create_streaming docstring.

* q-4+q-5 (_protocol.py:107 + 3 providers): MAX_REASONING_DISPLAY_BYTES
  was enforced via Python str slicing which counts code points, not
  UTF-8 bytes — 4-byte CJK/emoji glyphs would blow past the byte
  ceiling.  Renamed to MAX_REASONING_DISPLAY_CHARS to match actual
  behaviour.  Hoisted the 4-line truncation pattern into a shared
  _join_reasoning_with_cap helper in _protocol.py; each provider's
  extractor becomes a single line at the tail.

* q-6 (tests/_session_helpers.py): _NullUI + _make_session were
  duplicated verbatim between test_session_replay_reasoning.py and
  test_session_synth_reasoning_block.py.  Hoisted to a shared
  tests/_session_helpers.py module (importable, leading underscore so
  pytest doesn't try to collect it).  test_model_registry.py's
  _make_session has a different signature (registry/model_alias args
  + _FakeUI) and is not a candidate for sharing.

Nit

* q-7 (history_decoration.py:286): _make_provider_factory used a
  dict-as-cell workaround for closure read-only scope.  Replaced with
  the more idiomatic nonlocal pattern.

Lint + test gate

* ruff check + ruff format -- clean.
* mypy -- no issues across all 191 source files.
* pytest -m 'not live' -- 6115 passed (3 deselected).  Net +5 tests
  (4 audit-log discipline + 1 redacted_thinking dispatcher).

Refinements vs the dedupe output (caught during sanity rendering
the report)

* perf-1 fix preserved the try/except wrapper.  The original "wrap in
  to_thread" one-liner would have let an OperationalError bubble out
  instead of degrading to the fallback branch.

* q-3 fix explicitly documented the bifurcation rather than
  collapsing both sides to False.  "Pick False everywhere" would
  silently flip back-compat behaviour for direct provider callers.

* q-1 fix included the admin.js:5292 fallback site
  (m.persist_reasoning !== false) that the original threaded-change
  list missed.

* q-6 fix verified the third _make_session in test_model_registry.py
  is structurally different (different signature + different UI
  helper) and intentionally NOT a dedupe target.
2026-05-09 02:45:13 -07:00
Patrick Buckley 1873e7a758 feat(reasoning): persist reasoning text on history payload (Phase 1)
Surface stored Anthropic thinking blocks on /history responses so
refreshing the page rehydrates the reasoning bubble. Wire payloads
unchanged. Per-model operator knobs added to model_definitions for
both UI rehydration and (Phase 2) wire-build replay.

Why now: reasoning is already round-tripped via _provider_content for
Anthropic-with-thinking turns, but never surfaces on the history wire,
so a tab reload showed only the final answer with no rationale.
Operators also have no per-model lever to opt out of UI display or to
opt in to replay-to-model on subsequent calls.

What this change does

* Migration 052 adds two boolean columns to model_definitions:
  persist_reasoning (default 1) controls UI rehydration; replay_
  reasoning_to_model (default 0) reserved for Phase 2's wire-build
  shape filter. Mirrors the enabled column pattern (NOT NULL +
  integer server_default).
* LLMProvider Protocol gains extract_reasoning_text(provider_blocks)
  with concrete impls on AnthropicProvider (walks type=='thinking'
  blocks, joins with newline, caps at 64 KiB) and no-op stubs on
  OpenAIChatCompletionsProvider + OpenAIResponsesProvider. Google
  inherits the no-op via OpenAIChat. Phase 3 will wire the OpenAI
  Responses extractor once include=['reasoning.encrypted_content']
  is requested.
* turnstone.core.history_decoration gains a structural dispatcher
  extract_reasoning_text_from_provider_content keyed off the first
  block's type field (Anthropic 'thinking' / OpenAI Responses
  'reasoning' / Gemini 'thought' are non-overlapping by API design).
  Both history surfaces use it: _build_history calls the dispatcher
  directly (the SSE-replay path builds entry dicts from scratch),
  and the lifted make_history_handler runs the list-helper variant
  in the existing to_thread block.
* make_history_handler resolves persist_reasoning via three tiers:
  live session -> workstream_config.model_alias (the same key
  SessionManager uses to rehydrate the original model after process
  restart) -> conservative True default. Operator flag-flip takes
  effect uniformly on both warm and cold workstreams.
* Frontend: app.js replayHistory and coordinator.js role==='assistant'
  branch each call the existing reasoning-bubble construction (for
  app.js, the document.createElement pattern from the live SSE
  handler; for coord, the appendMsg('reasoning') helper) when
  msg.reasoning is non-empty. Reasoning bubbles render before the
  content bubble, matching live SSE order.
* Admin UI: two checkboxes ('Persist reasoning', 'Replay reasoning
  to model') in the model edit modal, plus override-pill display in
  the model row when set to non-default values.

What is intentionally out of scope

* Phase 2 -- ANTHROPIC_VALID_BLOCK_TYPES shape filter at
  _anthropic.py:312-316, _convert_messages replay_reasoning_to_model
  parameter, thinking-strip branch, _msg_text_chars token-calibration
  extension. The replay flag is stored but not consumed on the wire.
* Phase 3 -- OpenAI Responses include=['reasoning.encrypted_content'],
  Gemini include_thoughts spike, ModelCapabilities.supports_
  reasoning_replay.
* Phase 4 -- Local-model / chat-template reasoning persistence
  (session.py:3486 reasoning_parts accumulator).

Tests

* AnthropicProvider.extract_reasoning_text -- 13 unit tests covering
  None / empty / mixed / multi-block / cap / malformed / non-list
  inputs plus other-provider no-op verification (real provider
  instances, no mocks).
* extract_reasoning_for_history -- 10 dispatcher tests including
  block-type discriminator routing (thinking vs reasoning vs
  unknown), strip-when-flag-false, empty / non-dict guards, and
  cross-role isolation.
* _build_history -- 6 boundary tests through the real Anthropic
  extractor with stub sessions, including the registry-lookup
  failure default-True branch.
* make_history_handler -- 5 round-trip tests through real storage:
  the storage layer's reconstruct_messages decodes provider_data
  into _provider_content, and the helper extracts through the real
  AnthropicProvider. Includes the live-session flag honoring path,
  the cold-workstream workstream_config lookup path, and the
  no-alias default-True fallback path.
* Audit-log discipline -- 4 structural mock-and-assert tests that
  capture every Logger.info / warning / error call across the
  pipeline (extractor, dispatcher, list-helper, _build_history)
  and assert no captured payload contains a marker reasoning string.
* model_definitions storage -- 6 round-trip tests: default flags,
  explicit create with both flags, individual update of each flag,
  and list-includes-flags assertion.
* model_registry -- 4 tests: dataclass defaults, dataclass with
  explicit flags, DB-row-mapping with both flags, and pre-052
  legacy-row default-fallback.

Edge cases pinned by the test suite

* Pre-052 DB rows missing the new columns degrade to dataclass
  defaults (test_db_reasoning_flags_default_when_absent).
* Live session in memory has its flag honored (test_history_handler_
  with_persist_flag_false_via_live_session).
* Cold workstream resolves the flag via workstream_config +
  app.state.registry (test_history_handler_cold_workstream_resolves_
  via_workstream_config) -- this closes the gap where a process
  restart would have silently un-honored an operator flag-flip.
* Cold workstream without persisted model_alias falls through to
  default True (test_history_handler_cold_workstream_no_alias_
  defaults_true).
* Foreign / unknown / missing block types degrade silently to no
  reasoning field rather than misroute or crash.

Lint + test gate

* ruff check + ruff format -- clean.
* mypy -- no issues across all 191 source files.
* pytest -m 'not live' -- 6030 passed (3 deselected).
2026-05-09 02:45:13 -07:00
Patrick Buckley 6abb2698f7 fix: apply repair=False to all display-read load_messages call sites 2026-05-07 22:46:04 -07:00
Patrick Buckley c2cb6a7ea5 fix(replay): apply PR #488 review findings
Four Copilot findings on c6041c6 — all confirmed valid, all bounded
to authenticated-user prompt-injection scenarios but worth closing
before merge.

Wrapper-detect bypass (string + list branches of
``_apply_reminders_for_provider``):

The round-2 fix used ``content.startswith("<tool_output>\\n")`` to
detect already-wrapped content and skip ``escape_wrapper_tags``.  A
tool whose RAW output starts with that prefix (e.g. ``echo
'<tool_output>'``) would match and have its escape skipped, letting
literal ``<tool_output>`` / ``<system-reminder>`` tags reach the model
and impersonate a system envelope.  Replace the prefix check with
``extract_advisories_from_tool_envelope(content) is not None`` —
parsing requires the open AND matching close tags AND a structurally
valid envelope, raising the bypass bar significantly.

Mirror fix in the list-content branch so a tool emitting an unmatched
envelope as a text part can't bypass the per-text-part escape.

``_build_history`` legitimate-envelope drop:

The list-content drop path previously removed any text part starting
with ``<tool_output>\\n``.  A tool that legitimately outputs a
well-formed envelope (documentation viewer, code analyzer demoing the
wrapper, an echo tool) would have that part silently disappear on
replay.  Tighten the drop heuristic to require BOTH ``cleaned_text ==
""`` AND at least one extracted advisory — the structural signature of
the injected ``wrap_tool_result("", advisories)`` carrier we produce
in ``session.py`` for list-typed tool output.  A legitimate envelope
has non-empty inner body or no advisory blocks and survives the
projection.

Empty advisory body:

``queue_message`` accepts any non-None text including ``""`` and
whitespace-only strings.  ``_classify_advisory`` would return a
``user_interjection`` advisory with empty / whitespace body, which
``replayAdvisoriesAfterTool`` then renders as a featureless empty user
bubble.  Filter empty / whitespace-only bodies at classification time
so the wire-shape contract is uniform: no empty advisories ever ride
the wire.

Tests:

* ``test_apply_reminders_escapes_tool_output_starting_with_envelope_prefix``
  pins the structural-parser bypass close: a string starting with the
  envelope prefix but lacking a close tag still gets escaped.
* ``test_apply_reminders_escapes_list_text_part_with_unmatched_envelope_prefix``
  mirrors for the list-content branch.
* ``test_build_history_keeps_legitimate_envelope_text_part_with_body``
  pins that legitimate envelope output stays in the projected list.
* ``test_decorate_suppresses_empty_advisory_body`` and
  ``test_decorate_suppresses_whitespace_only_advisory_body`` pin the
  empty-body filter in ``_classify_advisory``.

Tests: 5923 passed, 3 deselected.  Lint + format + mypy clean.
2026-05-07 17:32:23 -07:00
Patrick Buckley eca4bb79e4 fix(replay): seam 1 splice + storage symmetry for queued user messages
Reverses the seam-2-only design from the prior commits on this branch.
Queued user messages arriving DURING a tool batch (Seam 1) splice into
the last tool result's envelope as ``UserInterjection`` advisories via
``wrap_tool_result``.  Messages arriving BETWEEN turns (Seam 2) drain
as a single trailing user row via ``_flush_queued_messages`` with
``user_feedback`` (operator text alongside an approval, e.g. "y, use
full path") folded in as a prefix.  Cancel/exception drains (Seam 3)
keep the existing ``_flush_queued_messages()`` call unchanged.

Why all three seams:

* Strict-template providers (Mistral, Llama via vLLM with stock chat
  templates) reject role-alternation violations.  A literal ``user``
  row mid-tool-batch breaks ``assistant(tool_calls) → tool → ... →
  assistant``; back-to-back ``user → user`` rows on the wire also fail.
* The seam-2-only design produced back-to-back ``user`` whenever
  ``user_feedback`` and queued items both fired — bug-1 from the round-1
  review.  Folding ``user_feedback`` as a prefix to the queue-drain
  collapses the two into one row.
* During-batch arrivals couldn't ride seam 2 — the splice was the only
  way to deliver same-turn without violating role alternation.

Storage symmetry:

Tool DB rows now store the wrapped ``output`` (envelope + advisories)
unconditionally — ``self.messages[i]['content']`` and
``conversations.content`` match exactly.  List-typed output (image /
structured MCP results) uses ``wrap_tool_result(raw_joined_text,
advisories)`` at save time so the persisted string is anchored on
``<tool_output>\n`` for the replay parser.  ``TOOL_RESULT_STORAGE_CAP``
is removed entirely; tools are responsible for bounding their own
output, storage faithfully represents in-memory.  Removing the cap
also simplifies the parser — no truncated-envelope edge case.

Replay extraction:

``decorate_history_messages`` (REST ``/history``) and ``_build_history``
(SSE replay, resume, rewind, retry, post-load, rename re-replay) both
call the public ``extract_advisories_from_tool_envelope`` helper to
pull the envelope back into structured ``advisories`` for JS replay.
Both string content and list-typed content (image+queued-message
combo) covered.  JS renders extracted advisories as normal user
bubbles after the tool block via the shared ``replayAdvisoriesAfterTool``
helper in ``shared_static/utils.js``.

Wrapper-tag escape and provider splice:

``escape_wrapper_tags`` now encodes pre-existing ``&`` first using an
``&amp;`` sentinel so tool output containing literal entity strings
(documentation viewers, code analyzers, web scrapers returning entity-
encoded markup) round-trips correctly.  Both encode and decode helpers
short-circuit on absence of ``<`` / ``&``.

``_apply_reminders_for_provider`` detects already-wrapped content
(string body and list text-part) by ``startswith("<tool_output>\n")``
and skips re-escape so existing envelopes survive intact when a tool
message also carries ``_reminders`` (the queued-message + tool-error
co-occurrence case is now common).

``decorate_history_messages`` runs in ``asyncio.to_thread`` to keep
MB-scale string work off the event loop.

Other cleanup:

* ``_collect_advisories`` delegates the queue drain to a named helper
  ``_drain_queued_messages_to_advisories`` so the swap-and-clear pattern
  lives next to ``_flush_queued_messages``'s identical pattern and the
  side-effect is documented at the call site.
* Preamble strings + body marker for ``UserInterjection`` round-trip
  detection moved to module-level constants in ``tool_advisory.py``;
  imported by ``history_decoration.py`` so a producer-side rephrase
  can't silently desync the parser.
* ``_send_with_mocks`` ctxmgr extracted in ``test_session.py`` — the
  six new send-driven tests share an 8-deep ``patch.object`` block.
* ``replayAdvisoriesAfterTool`` shared helper in
  ``shared_static/utils.js``; ``app.js`` and ``coordinator.js`` both
  invoke it.
* Dead truncation-pill CSS removed (``.tool-output-truncated`` and
  ``.coord-tool-truncated``); the JS that added these elements went
  away with ``TOOL_RESULT_STORAGE_CAP``.
* Tautological tests (``TestBuildHistoryAdvisoryPropagation``)
  replaced with production-realistic round-trip tests built from
  ``wrap_tool_result(...)`` envelopes — REST and SSE-replay surfaces
  pinned to the same wire shape; full DB round-trip pinned end-to-end.

Negative-tested:

* Reverting the prefix-merge in ``_flush_queued_messages`` produces
  back-to-back ``user`` rows, breaking
  ``test_user_feedback_and_queued_coexistence_single_row_with_prefix``.
* Reverting the ``extract_advisories_from_tool_envelope`` call in
  ``_build_history``'s tool branch leaves the envelope verbatim in
  wire content, breaking the round-trip tests.
* Reverting the wrapper-detection in ``_apply_reminders_for_provider``
  entity-encodes the existing envelope's literal tags, breaking both
  the string-content and list-content envelope-preservation tests.
* Reverting the ``wrap_tool_result(raw_text, advisories)`` projection
  at the DB save site produces a string starting with the original
  raw text, breaking
  ``test_tool_db_row_round_trips_list_output_with_advisories``.

Tests: 5918 passed, 3 deselected.  Lint + format + mypy clean on
touched files.
2026-05-07 17:32:23 -07:00
Patrick Buckley 3aa9f53fd8 fix(session): metacog reminders ride a side-channel, not user content
User-channel metacognitive nudges (correction, denial, resume, start,
completion) used to be spliced into ``user_msg["content"]`` permanently,
which leaked the ``<system-reminder>`` envelope into every consumer of
``self.messages`` — UI replay (mitigated by a regex strip in /history),
compaction, title generation, and any future channel adapter that
echoes conversation context.  The /history strip was a band-aid;
compaction and title-gen still saw the raw spliced text.

Switch to a side-channel: ``_attach_pending_user_reminders`` writes the
rendered reminder list to ``user_msg["_reminders"]`` (sibling key,
leading-underscore convention shared with ``_attachments_meta`` /
``_provider_content``).  At the provider boundary, a new
``_apply_reminders_for_provider`` builds a transient shallow-copy with
the reminder spliced into ``content``; the original message dict
stays clean.  ``sanitize_messages`` drops the sibling key on the wire.

Once-per-session-not-per-turn semantics for the wire: after stream
success the loop calls ``_mark_reminders_delivered``, which flips a
``_reminders_delivered`` flag on every user message that carried
reminders into that call.  ``_apply_reminders_for_provider`` skips
already-delivered messages so the model sees each reminder exactly
once (the turn it advised).  ``_build_history`` ignores the delivered
flag entirely, so reconnecting tabs render the same nudge bubble the
originating tab saw via the live ``user_reminder`` SSE event.

UI surface:

  - ``SessionUIBase.on_user_reminder`` enqueues a
    ``{type: "user_reminder", reminders: [...]}`` SSE event with the
    same shape ``_build_history`` surfaces.
  - ``app.js`` renders a ``.msg.user-reminder`` bubble (yellow accent,
    pill-styled) anchored above the user message it advises, both
    live and on history replay.
  - ``replayHistory`` renders ``addUserMessage`` before
    ``addUserReminder`` so the anchor lookup finds the just-rendered
    turn (not a prior one).
  - Multi-tab caveat documented inline: non-originating tabs receive
    no ``user_message`` SSE event today, so a reminder may anchor to
    a stale prior bubble until ``/history`` reload corrects it.

Pre-existing bug surfaced by the audit: cancel handlers
(``GenerationCancelled`` / ``KeyboardInterrupt`` / generic
``Exception``) in ``ChatSession.send`` cleared
``_pending_tool_advisories`` but not the user-channel buffer.  Both
now drain through a shared ``_drain_pending_advisories`` helper.

Removed the ``/history`` regex strip — the side-channel approach
makes it redundant.  Hoisted ``escape_wrapper_tags`` +
``render_system_reminder`` imports to module top (called 2-3× per
turn).

Tests:

  - ``TestApplyRemindersForProvider`` — pass-through-by-reference,
    string + list content splice, escape on user-typed wrapper tags,
    multi-reminder ordering, source-untouched invariant, delivered
    flag skip path, fallback for unexpected content shape.
  - ``TestMarkRemindersDelivered`` — flag idempotency, no-reminders
    no-flag, only marks user messages with reminders.
  - ``TestUpdateTokenTableMsgsParam`` — calibration uses pre-built
    msgs when provided, falls back when not.
  - ``TestUserAdvisoryCancelClear`` — all three cancel branches drain
    the user buffer.
  - ``TestReminderSidechannelIsolation`` — compaction's
    ``_format_messages_for_summary`` and the title-gen extraction
    loop cannot see reminders by construction.
  - ``TestSessionUIBaseUserReminderHook`` — ``on_user_reminder``
    enqueues the right SSE shape.
  - ``TestBuildHistoryReminderPropagation`` — ``entry["reminders"]``
    propagation, absent / empty / multi / coexist-with-attachments
    cases, malformed input filtering, all-malformed elision.
  - ``test_sanitize_messages_strips_underscore_sibling_keys`` covers
    ``_reminders`` and ``_reminders_delivered``.
2026-04-30 03:13:37 -07:00
Patrick Buckley 1f7d6ad23b perf(api): offload tenant_check to thread on lifted session handlers (#449)
* perf(api): offload tenant_check to thread on lifted session handlers

Every make_*_handler factory in turnstone/core/session_routes.py invoked
cfg.tenant_check(request, ws_id, mgr) synchronously inside its async
handler. For the interactive surface tenant_check chains through
_interactive_tenant_check → _require_ws_access → resolve_workstream_owner,
which short-circuits on mgr.get(ws_id) for warm cache but falls through
to a synchronous get_workstream_owner SQL call on a cold cache,
blocking the event loop for the duration of the storage round-trip.

Wrap each of the 8 call sites (approve, close, cancel, events, history,
detail, send, dequeue) in await asyncio.to_thread(...) — mirroring the
existing storage-offload pattern at make_history_handler's other call
sites. Coord wires tenant_check=None and is unaffected. Five handlers
gain a local import asyncio (matching the per-handler lazy-import
convention in this module). Centralizes the offload rationale on
SessionEndpointConfig.tenant_check's field docstring.

Adds two regression tests in TestTenantCheckOnReadEndpoints that wire
the real resolve_workstream_owner as tenant_check and force the
storage fall-through path the existing class only stubbed past with
fake allow/deny callables.

* test(api): spy asyncio.to_thread to pin tenant_check offload

Copilot flagged the cold-cache regression tests for asserting the
response shape but not the offload itself: reverting
await asyncio.to_thread(cfg.tenant_check, ...) to the sync call shape
would still leave the storage fall-through working and the tests
green. Patch asyncio.to_thread inside both tests with an async spy
that records every offloaded callable, then assert cold_check is in
the call list — sanity-checked by reverting the history wrap locally
and watching the assertion bite (offloaded only contained
storage.get_workstream + storage.load_messages, missing cold_check).
2026-04-28 23:56:43 -07:00
Patrick Buckley 353ff4d18b feat(coord): inline tool-batch construct replaces approval dock (#447)
* feat(coord): inline tool-batch construct replaces approval dock

The pinned bottom approval-dock didn't scale: a 10-call spawn_workstream
fan-out filled the whole pane with a wall of repeated verdict chips,
and the call → approval → result lifecycle was split across three
disconnected surfaces (.msg.tool bubble + dock + .msg.tool result).

Replaces it with one chat-stream construct per dispatch turn that
pairs each tool call with its result and embeds the approval gate:

  - .coord-tool-batch--solo      single-call serial turn
  - .coord-tool-batch--parallel  ≥2 calls; rows share a left rail
                                 + per-row tick so they read as
                                 siblings of one assistant decision

Lifecycle: rows render with optional "judge evaluating…" placeholder,
upgrade in place when intent_verdict arrives, and on tool_result the
output lands paired under the originating row.  When the batch needs
approval, one Approve/Deny/Always action row renders inside the
construct (envelope-level — server semantics resolve siblings
together).  After approval_resolved the action row morphs into a
✓ approved / ✗ denied status pill that stays as a receipt.

Critical bug closed: when a page reload races a pending approval,
pre-scan tool_call_ids in history; turns whose call_ids have no
matching tool result are rendered pending (not resolved-approved).
The SSE approve_request replay then upgrades the existing batch
in place — drops --approved/--denied, adds --pending, swaps the
status pill for actions, and assigns activeBatch.  Without this
the operator was locked out of any approval pending at reload.

Defence-in-depth follow-ups from the same review:

  - approval_resolved falls back to a DOM lookup if activeBatch
    is null (cross-tab resolution where this tab never set it).
  - _appendVerdictLineTo dedupes via a row.dataset.verdictSig so
    SSE reconnect storms + repeat intent_verdict events don't
    tear down + rebuild an unchanged verdict line.
  - judgeVerdicts Map soft-capped at 500 entries (FIFO eviction)
    via _cacheJudgeVerdict.
  - toolRows entries hold {batch, row} only — the originating
    item payload is no longer pinned for the page lifetime.
  - _scheduleScroll coalesces messagesEl.scrollTop writes through
    requestAnimationFrame so history replay doesn't reflow once
    per appended message.
  - Rationale <details> now inserts immediately after the verdict
    line (was tail-appending, breaking ordering once a result
    landed below).
  - .coord-tool-batch--error wired: _appendResultToRow lifts a
    row's error onto the enclosing batch; _renderBatchRow does
    the same for policy-blocked rows at construction.
  - _buildStatusPill extracted; both _morphBatchResolved and the
    appendToolBatch resolved-replay branch route through it.

Removed: ~248 lines of dead .approval-dock CSS, the dock <aside>
element from index.html, and the dead helpers showApproval's
prior body, hideApproval, claimApprovalFocus,
claimApprovalFocusForVerdict, applyJudgeVerdictToRow,
applyJudgePendingToRow, ensureDctxAfterRow, removeRationale,
setApprovalButtonsDisabled, the appendToolCall single-row wrapper,
and window.coordApprove.  Five stale comment blocks referencing
the dock as if live also swept.

Children-tree's renderApprovalBlock is independent and untouched
(different surface, different .approval-block / .approval-pill
vocabulary).

* fix(coord): close four Copilot review gaps on PR 447

Copilot review on caa07e6 flagged four follow-ups:

1. History replay was rendering EVERY orphan tool_calls turn (one
   that lacks a matching tool result message) as `pending: true,
   judgePending: true`.  That paints Approve/Deny on turns that
   could be just running — auto-approved-and-still-in-flight, or
   already-approved-and-still-in-flight — and clicking would 409
   because the call_id isn't in `pending_items`.  Add a new
   `--running` state for the orphan case (no actions, neutral
   accent stripe).  SSE then upgrades in place: `--running` →
   `--pending` when `approve_request` replays, or `--running` →
   `--auto` when `tool_info` replays.  Tool_result events still
   route into the rows for the third case (already-approved + in
   flight) since `toolRows` is populated.  Kicker text reads
   "Running · Parallel N" while ambiguous, so the operator can
   tell the in-flight-replay state apart from a fresh "Parallel ·
   N tools" auto-approved batch.

2. Removing the dock also removed its `aria-live="assertive"`
   region — pending tool-batches now append into the polite
   `#coord-messages` log (which gets flipped to `aria-live="off"`
   during streaming), so a screen reader could miss the
   action-required signal.  Add an off-screen
   `aria-live="assertive"` `#coord-sr-announcer` region and route
   "Approval required: <name> + N more" through it whenever a
   pending batch is created OR an upgrade-in-place promotes a
   running batch to pending.  Also mark pending batches with
   `role="region"` + a matching `aria-label` so SR landmark
   navigation surfaces them; both are dropped on resolve so the
   resolved batch stops claiming the landmark.

3. `_resolveBatchAction` was selecting the first row whose
   `data-call-id` was set and that wasn't `.error` — but
   `approve_request` envelopes carry the FULL items list,
   including auto-approved siblings whose `needs_approval=false`
   means the server's `pending_items` won't recognise their
   call_id (→ 409 on submit, or resolves the wrong gate).  Tag
   rows that are genuinely in `pending_items` with
   `data-needs-approval="1"` at construction (and during
   upgrade-in-place when SSE arrives), and select against that
   selector specifically.  Restores the legacy
   `pendingApprovalCallId` contract that filtered on
   `needs_approval` before the dock was retired.

4. The `.coord-tool-row-result` comment claimed the styles applied
   a click-to-expand "collapsed" affordance like the interactive
   UI's `.tool-output.collapsed`, but the implementation only set
   `max-height: 240px; overflow: auto` (a scroll pane, not a
   collapse with expand control).  Update the comment to describe
   what the rules actually do and explain the deliberate
   divergence from interactive (coord is a diagnostic-leaning
   read-once surface; an internal scroll pane reads with lower
   friction than a click-to-expand control on the operator's
   primary monitoring view).

No Python touched; node --check on coordinator.js clean.

* fix(coord): restore reload-time pending approval gate

Agent-Logs-Url: https://github.com/turnstonelabs/turnstone/sessions/30f630fe-3ded-4abe-991b-b5a95f699127

Co-authored-by: eous <13773563+eous@users.noreply.github.com>

* feat(api): expose pending_approval on workstream detail response

PR 447 / 93cb3d9 (Copilot autonomous follow-up) added a JS path that
reads ``wsSnapshot.pending_approval_detail`` off the
``GET /v1/api/workstreams/{ws_id}`` snapshot in coordinator.js
init() so a freshly-loaded chat tab can paint the inline approval
gate immediately at reload, without waiting for the SSE
approve_request replay (which leaves a brief --running flash on the
inflight orphan placeholder).

But the server's ``WorkstreamDetailResponse`` schema only declared
``{ws_id, name, state, user_id, kind}`` and the lifted
``make_detail_handler`` matched: nothing was populating
``pending_approval`` or ``pending_approval_detail`` on the wire.
The frontend block silently no-op'd at runtime; Copilot's
accompanying assertion only grep'd the JS source for the literal
strings, so it stayed green while the actual contract was missing.

Extend the contract to match the Copilot frontend:

  - Add ``pending_approval: bool`` + ``pending_approval_detail:
    PendingApprovalDetail | None`` to ``WorkstreamDetailResponse``,
    same shape as the dashboard / cluster live projection.
  - ``make_detail_handler`` reads ``ws.ui._pending_approval`` (only
    treats it as live when ``isinstance(_, dict)`` so MagicMock-
    based unit tests don't trip the path) and calls
    ``ui.serialize_pending_approval_detail()`` to fill the detail.
    A serializer raise falls back to ``pending_approval=True`` +
    ``detail=None`` instead of 500ing the whole response — SSE
    replay still carries the authoritative payload.
  - ``test_returns_workstream_fields`` updated for the two extra
    fields (False / None on a MagicMock UI).
  - ``test_pending_approval_fields_propagate_from_ui`` is the new
    behavioural test: stub a UI with a realistic
    ``_pending_approval`` dict + serializer return, assert the JSON
    surfaces ``pending_approval=True`` + the items list.
  - ``test_pending_serializer_failure_falls_back_to_bool_only``
    pins the defensive degradation so a future serializer
    regression can't 500 every reload.

Tests: 4822 pass (3 deselected live).  Ruff + mypy clean.

* fix(coord): three regressions on PR 447 inline tool-batch refactor

Three regressions reported during operator harness shakedown, all
landed by the inline tool-batch refactor in caa07e6:

1. ``stripAnsi`` ReferenceError on every ``tool_result``.
   ``_appendResultToRow`` called ``stripAnsi(output || "")`` but the
   helper only existed in ``ui/static/app.js`` — coord.js never
   imported or defined it.  The thrown ReferenceError propagated up
   through ``appendToolResult``, aborting the SSE handler before
   ``loadTasksDebounced()`` could fire, AND the result block never
   appended to the row, AND history replay's tool-message loop
   bailed out at the first orphan-tool-result.  Three reported
   bugs (tasks pane stops auto-refreshing, tool output missing in
   the modal, reload only rebuilds the conversation up to the first
   tool result), one root cause.

   Fix: hoist a local ``stripAnsi`` mirroring the interactive UI's
   regex.  Keep it local rather than centralised — coord and
   interactive tool-output paths have different rendering
   strategies, and the interactive helper isn't on the shared
   module surface today.

2. JSON tool output rendered as a single unreadable line.  Coord
   tool surfaces (``list_nodes``, ``tasks``, ``spawn_workstream``,
   ...) emit JSON by default, and ``textContent = stripAnsi(raw)``
   showed the whole envelope on one line.  The parent
   ``.coord-tool-row-result`` already has ``white-space: pre-wrap``
   so a ``JSON.stringify(parsed, null, 2)`` body lays out as
   intended without a nested ``<pre>``.  Non-JSON / unparseable
   output falls through to the raw cleaned string.

3. Header tier badge stuck on ``⚙ heuristic`` after the LLM judge
   landed an upgraded verdict.  ``_pickBatchTier(items)`` ran once
   at batch-creation time; later ``intent_verdict`` SSE events
   updated the per-row chip via ``_appendVerdictLineTo`` but never
   refreshed the head.

   Fix: persist the verdict's tier on ``row.dataset.verdictTier``
   (+ ``verdictModel`` when set), add ``_refreshBatchTier(batch)``
   that scans the rows and computes the cross-row best tier (LLM
   beats heuristic), and call it from ``_appendVerdictLineTo``
   whenever a row writes a verdict.  ``_pickBatchTier`` gets the
   same prefer-LLM scan so the initial render is consistent.  The
   ``intent_verdict`` cache entry tags ``tier: "llm"`` so a late
   verdict landing on a previously heuristic-only row escalates
   the badge correctly.

No Python touched; node --check on coordinator.js clean.

* fix(coord): close five Copilot review gaps on PR 447

Five distinct findings from the second Copilot pass on the inline
tool-batch refactor (the sixth — stripAnsi ReferenceError — already
shipped in 77dc24e):

1. CSS rail tucks never matched.  The ``--first / --last`` row trims
   used ``:first-of-type`` / ``:last-of-type``, but the batch
   contains other ``<div>`` siblings (.coord-tool-batch-head,
   .coord-tool-actions / .coord-tool-status) — the
   structural-pseudo-class is type-based (``div``), not class-
   based, so the first .coord-tool-row is not the first ``<div>``
   in the parent.  Selector silently no-op'd, leaving the rail
   butting against the inner top/bottom edges of the batch.  Fix:
   apply explicit ``.coord-tool-row--first`` / ``--last`` markers
   in JS at row-build time and key the CSS off them.

2. Upgrade-in-place left stale ``data-needs-approval`` markers on
   non-pending sibling rows.  The original block only added the
   attribute for items where ``needs_approval=true``, never
   clearing it for rows whose earlier (replay-time) shell tagged
   them.  ``_resolveBatchAction`` could then pick a non-pending
   row's call_id, yielding a 409 stale call_id on approve / deny.

3. Upgrade-in-place left row-level status pills out of sync with
   the SSE-authoritative item shape.  When a ``--running`` orphan
   gained a ``tool_info`` envelope, the ✓ auto pill never
   appeared; when it gained an ``approve_request`` envelope with
   policy-blocked siblings, the ✗ blocked pill / ``.error`` class
   were missed.  Batch-level state classes flipped, but per-row
   visual cues lagged.

   Fix for 2 + 3: extract ``_refreshRowStatus(row, item)`` from
   ``_renderBatchRow``.  It clears prior ``data-needs-approval`` +
   pills and re-applies from the item, preserving runtime
   ``tool_result`` errors via the new
   ``.coord-tool-row-result--error`` marker on the result block.
   Both ``_renderBatchRow`` (initial render) and the
   upgrade-in-place loop now route through it, so the two paths
   can't drift.

4. History replay defaulted ``item.needs_approval = true`` on
   every synthesized tool call.  ``_renderBatchRow`` then tagged
   the row with ``data-needs-approval="1"`` regardless of whether
   the call genuinely needed approval.  Combined with the missing
   clear in finding 2, an SSE upgrade with a mixed envelope kept
   incorrect markers on auto-approved siblings.  Drop the
   replay-time default; let SSE supply the authoritative bit when
   the upgrade fires (``_refreshRowStatus`` reads it from the
   item).

5. Tool result routed into an existing batch row didn't trigger
   ``_scheduleScroll()``.  Result blocks grow ``scrollHeight``;
   without the rAF-coalesced scroll the user pinned at the bottom
   loses their pin when the row inflates.  Add the call after
   ``_appendResultToRow`` in the early-return path so this branch
   matches ``appendMsg``'s pinning behaviour.

Plus comment-only:

6. Detail-handler comment claimed "the JSON omits the section"
   when the UI doesn't expose ``serialize_pending_approval_detail``,
   but the response always includes both keys (with ``False`` /
   ``null`` for the bool / detail).  Updated to match the actual
   shape.

Tests: ``test_workstream_endpoints.TestDetailInteractive`` +
coordinator-detail + page tests pass (14 / 0 failed). Ruff +
mypy clean.  ``node --check`` on coordinator.js clean.

* fix(coord): close 17 review findings on PR 447

Second /review pipeline pass surfaced 16 confirmed findings (1 sec
major, 1 bug major, several minor + nit); operator harness shakedown
+ this commit's stale-comment sweep adds one more.  All addressed
here.

Security:

  sec-1 (major) — make_detail_handler + make_history_handler in
  session_routes.py now invoke ``cfg.tenant_check`` after ws_id
  validation, matching every other lifted session verb (send /
  approve / close / cancel / events / attachments).  Pre-fix the
  detail response carried 5 low-data fields and history exposed
  message rows; PR 447 added pending_approval_detail to detail
  (tool previews + LLM judge reasoning) which made cross-tenant
  reads via the missing gate a real disclosure on the interactive
  surface (coord wires tenant_check=None and is unaffected).  Plus
  4 new regression tests in TestTenantCheckOnReadEndpoints that
  wire a tenant_check function into the test cfg and assert the
  gate fires on detail + history.

Bug fixes:

  bug-1 (major) — history replay used to render every fully-
  resolved tool batch as ``resolved: { approved: true }`` regardless
  of the persisted tool result content.  A denied tool round-trip
  showed the green "✓ approved" pill alongside the persisted
  "Denied by user" result text — directly contradictory state.  Fix:
  pre-scan classifies each tool message via a ``callOutcomes`` Map
  by inspecting content prefix ("Denied by user" / "Blocked by
  tool policy" / "Error:") and ``m.is_error``.  Assistant tool_calls
  render ``resolved.approved=false`` when any call's outcome is
  "denied"; the existing --running fallback covers orphan turns
  (any call lacking an outcome).

  bug-2 — _verdictSig joined recommendation/risk_level/confidence/
  reasoning only.  When a late LLM verdict text-matched the earlier
  heuristic verdict, the dedupe early-return fired before the
  row's dataset.verdictTier was updated, so _refreshBatchTier
  never escalated the header from "⚙ heuristic" to "⚖ llm".
  Fix: include verdict.tier and verdict.judge_model in the
  signature (with a "\x1f" separator instead of the empty join,
  reducing field-boundary collision risk).

  bug-3 — history replay's tool-result rendering hardcoded
  isError=false.  A runtime tool error on reload rendered without
  the .error class, --error stripe, or "✗ error:" lead.  Fix:
  the same callOutcomes pre-scan that drives bug-1's denial path
  also classifies "Error:" prefixes; appendToolResult now receives
  isError=callOutcomes.get(callId) === "error".

  bug-4 — approval_resolved derived ``wasAlways`` exclusively from
  this tab's ``batch.dataset.requestedAlways``; cross-tab "Always"
  click never propagated to peer tabs' status pill.  Fix: server's
  resolve_approval now takes a keyword ``always`` arg and includes
  it on the SSE event body; client prefers ``ev.always`` and falls
  back to the dataset stash for the hot-deploy window where the
  SSE event might briefly omit the field.

  bug-5 (nit) — appendToolBatch's create-new path overwrote
  toolRows entries unconditionally.  A partial-mapped envelope
  (some call_ids previously seen, some new) silently orphaned the
  prior batch's row pointers.  Fix: detect the partial overlap,
  console.warn, unmap the stale entries before the new batch
  claims them.

Performance:

  perf-1 — _refreshBatchTier did a querySelectorAll per verdict
  insertion; for an N-row batch upgrade this was O(N²) DOM walks.
  Coalesce via queueMicrotask + a _tierDirtyBatches Set so a burst
  of N verdict updates collapses into ONE tier scan.  Synchronous
  body extracted to _refreshBatchTierImmediate (called from the
  microtask flush).

  perf-2 — _appendResultToRow pretty-printed JSON via
  JSON.parse + JSON.stringify(parsed, null, 2) on every tool
  result with no size cap.  A 100KB JSON output stalled the main
  thread; 10 parallel tool_result events compounded.  Fix: gate
  on cleaned.length <= 32 KiB AND a first-char check (0x7B / 0x5B)
  so plain text + oversized payloads skip the parse.  Parent CSS
  is white-space: pre-wrap so raw text still wraps.

Quality:

  q-1 — deleted dead row.dataset.funcName write (no readers).

  q-2 — extracted _formatTierLabel(llmModel, hasHeuristic) shared
  by _pickBatchTier (item-driven) and _refreshBatchTierImmediate
  (dataset-driven).  Single source of truth for the tier label
  literals.

  q-3 — extracted _pendingKickerText(items) used by both the
  upgrade-in-place and fresh-build paths in appendToolBatch.

  q-4 — added string-presence assertions to
  test_coordinator_js_exposes_inline_approval_helpers covering
  the new tool-batch helpers (appendToolBatch, _morphBatchResolved,
  _resolveBatchAction, _refreshBatchTier, _refreshRowStatus), the
  --running / --pending state classes, and the callOutcomes
  outcome classifier.

  q-5 — renamed _announcePolitelyAssertive → _announceAssertive.
  Function unconditionally writes into the aria-live="assertive"
  region; "politely assertive" was contradictory.

  q-6 — rescoped the test docstring to acknowledge it covers two
  layers (Chunk 3 children-tree + PR 447 tool-batch).

  q-7 — tightened pending_approval_detail: Any → dict[str, Any]
  | None in make_detail_handler.  Mypy-confirmed.

Plus the third /review pass's q-1 stale-comment sweep:
  _resolveBatchAction's comment still claimed the server doesn't
  echo ``always`` on approval_resolved — wrong post-bug-4-fix.
  Updated to reflect that the dataset stash is now backward-compat
  fallback only, not the primary source.

Tests: 4826 pass (+4 new from TestTenantCheckOnReadEndpoints, plus
expanded assertions in TestDetailInteractive).  Ruff + mypy clean.
``node --check`` on coordinator.js clean.

Verifier confirmed all 16 findings; pass-3 /review on the
addressing-commit surfaced only 0 critical / 0 major / 2 minor /
2 nit, none blocking.  The two pass-3 minor findings are
pre-existing patterns across all lifted verbs (sync tenant_check
inside async handlers) and best addressed in a dedicated follow-up
PR auditing the whole lifted-verb surface.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eous <13773563+eous@users.noreply.github.com>
2026-04-28 23:01:18 -07:00
Patrick Buckley d15f182b80 fix(coord): tree UI not updating when LLM deletes workstream (#429)
* fix(coord): tree UI not updating when LLM deletes workstream

The coord LLM's `delete_workstream` tool wiped the storage row but
fired no SSE event, so a long-lived dashboard tab kept the deleted
child visible (with its last-known idle/closed state) until a full
reload. A coordinator that spawns→completes→deletes children would
leave an ever-growing tree.

Fix: add `SessionManager.delete()` that drops the in-memory slot if
present and emits `ws_closed` with `reason="deleted"` (mirrors
`close()`'s shape). Wire `delete_workstream_endpoint` to call it
after the storage delete succeeds, snapshotting the workstream's
name into the event payload before the row is wiped. The cluster
collector → coord adapter chain re-emits as `child_ws_closed`; the
browser's existing `handleChildClosed` already keys on
`reason === "deleted"` to mark the row, so no JS changes needed.

Event emit is best-effort — a fan-out failure logs a warning but
doesn't roll back the storage delete (the row is already gone).

* fix(coord): apply Copilot review feedback on PR #429

- server.py: clarify that ``name`` is forwarded to mgr.delete only
  (not into the audit detail) — comment previously claimed both.
- test_session_manager.py: extract ``mgr.delete(ws_id)`` to a local
  before asserting (CodeQL: no side-effecting calls inside ``assert``,
  which would be stripped under ``python -O``).
- test_workstream_endpoints.py: docstring said "Yield" but the
  fixture ``return``s; switch to "Return".
2026-04-27 14:44:01 -07:00
Patrick Buckley d555816016 refactor(core): lift history + detail verb bodies across both kinds (Stage 2 verb lift)
Last verb-shape lift before v1.5.0 stable can tag. Adds two new
factories to ``turnstone/core/session_routes.py``:

- ``make_history_handler(cfg)`` — body lifted from coord's
  ``coordinator_history`` near-verbatim. ``?limit=`` query param
  defaults to 100, clamps to [1, 500], malformed values fall back
  to 100. Storage operations (``get_workstream`` on the
  storage-fallback path, ``load_messages`` for the row read) now
  run via ``asyncio.to_thread`` (was inline pre-lift on coord).
- ``make_detail_handler(cfg)`` — body lifted from coord's
  ``coordinator_detail``. Lazy-rehydrates a closed/evicted
  workstream via ``mgr.open()`` on miss; mirrors
  :func:`make_open_handler`'s exception envelope (``ValueError``
  → 503 with the session-factory's remediation text; bare
  ``Exception`` → correlation_id'd 500 with the per-kind noun
  via ``cfg.audit_action_prefix``).

NO new ``SessionEndpointConfig`` fields — the factories reuse
``permission_gate``, ``manager_lookup``, ``not_found_label``,
``audit_action_prefix``, and (for history's storage-fallback
kind check) ``list_kind`` — all already wired by both production
lifespans for the list/saved factories.

Coord side: ``coordinator_history`` and ``coordinator_detail``
standalone handler bodies removed from ``console/server.py``;
``register_session_routes`` now wires
``history=make_history_handler(coord_endpoint_config)`` and
``detail=make_detail_handler(coord_endpoint_config)``.

Interactive side: GAINS both endpoints as a feature gain. Pre-lift
interactive had no ``GET /v1/api/workstreams/{ws_id}`` and no
``GET /v1/api/workstreams/{ws_id}/history`` — SDK consumers had to
subscribe to ``/events`` SSE just to read display fields or
message rows. The same lifted factories are wired with the
interactive endpoint config; cross-kind isolation is preserved on
both sides (history via ``cfg.list_kind`` storage-fallback gate
+ fail-loud-on-misconfig 500; detail via ``mgr.open()``'s internal
kind check).

Pydantic schemas: ``CoordinatorDetailResponse`` /
``CoordinatorHistoryResponse`` removed from ``console_schemas.py``;
``WorkstreamDetailResponse`` / ``WorkstreamHistoryResponse`` added
to ``server_schemas.py`` (mirrors the list lift's pattern for
``WorkstreamInfo``). Both server and console OpenAPI specs
reference the unified schemas; ``server_spec.py`` gains
``EndpointSpec`` entries for the new interactive endpoints. TS
SDK gains both interfaces in ``sdk/typescript/src/types.ts``;
``openapi-{server,console}.json`` regenerated.

Tests: 6 new coord regression/parity tests in
``test_coordinator_endpoints.py`` (limit clamping, cross-kind 404
on storage fallback, storage-only history, detail 503 on
session-factory misconfig, detail 500 with correlation_id on
unexpected rehydrate failure, history swallows
``load_messages`` exception → 200 with empty messages). 10 new
interactive parity tests in ``test_workstream_endpoints.py``
(``TestHistoryInteractive`` + ``TestDetailInteractive``). 1 new
openapi spec test pinning the server-side ``?limit=`` query param.
Total: ``4490 → 4491`` after the new exception-swallow
regression test landed. ``ruff check`` clean, ``mypy`` clean on
touched files.

/review pipeline (4 finders → verify → dedupe) caught 1 Minor
defense-in-depth (bug-1/sec-1, merged: ``make_history_handler``
fail-closed gate when ``cfg.list_kind is None``, mirroring
``make_saved_handler``'s same gate) + 1 Minor test-helper rename
(q-1: ``_interactive_history_cfg`` → ``_interactive_endpoint_cfg``)
+ 4 Nits (q-2 unused fixture parameter, q-3 CHANGELOG TS SDK
mention, q-4 missing exception-swallow regression test, q-5
misleading test comment) — all addressed in the same commit.
2026-04-26 15:48:26 -07:00
Patrick Buckley f9ed4d3071 refactor(core): lift open verb body across both kinds (Stage 2 verb lift) (#414)
* refactor(core): lift open verb body across both kinds (Stage 2 verb lift)

The interactive ``POST /v1/api/workstreams/{ws_id}/open`` and coord
``POST /v1/api/workstreams/{ws_id}/open`` handlers now share one
body via ``make_open_handler(cfg, *, audit_emit=None)``. Per-kind
divergence captured by two new ``SessionEndpointConfig`` fields:

* ``open_resolve_alias: AliasResolver | None`` — interactive wires
  ``resolve_workstream`` so callers can pass user-friendly aliases
  in the path param. Coord wires ``None``.
* ``open_post_load: OpenPostLoad | None`` — interactive wires
  ``_interactive_open_post_load`` (display-name sync + UI replay
  via ``clear_ui`` + history + handler-side ``ws_created`` enqueue
  onto the global SSE queue). Coord wires ``None`` and relies on
  the cluster collector fan-out from
  ``CoordinatorAdapter.emit_rehydrated``.

Plus an optional ``audit_emit`` parameter (interactive wires
``_audit_workstream_opened``; coord wires ``None`` — coord doesn't
audit open today). Old ``open_workstream`` (server.py) +
``coordinator_open`` (console/server.py) bodies deleted.

**Load-bearing fix** (§ Post-P3 reckoning item #3 from the planning
docs): pre-lift interactive's ``open_workstream`` called
``mgr.create(ws_id=resolved_id)`` + ``ws.session.resume(...)`` to
rehydrate, bypassing ``mgr.open()`` entirely. After the lift both
kinds route through ``mgr.open()`` — which makes
``InteractiveAdapter.emit_rehydrated`` reachable on interactive
(it had been dead-by-routing) and gives the manager a single
rehydrate code path to maintain. ``emit_rehydrated`` stays a
documented no-op stub on the interactive adapter; the handler-side
``ws_created`` enqueue from the post-load callback is the
load-bearing emission for the SSE consumers.

Behaviour changes for interactive callers (documented in CHANGELOG):

* **Cross-kind open returns 404** (was 400 with
  ``"Workstream is not an interactive kind"``). The lift consolidates
  on ``mgr.open()``'s single ``None``-return contract for missing /
  wrong-kind / tombstoned rows. Security boundary unchanged.
* **Already-loaded response uses ``ws.name`` directly** (was
  ``get_workstream_display_name(resolved_id) or resolved_id``).
  The dashboard listing endpoint still resolves aliases on its own
  pass, so the user-visible name in the tab strip isn't affected.

Two /review fixes folded in:

* **Resume failures now return 5xx instead of broken-200.**
  ``SessionManager.open()`` previously caught and ``log.debug``-
  swallowed exceptions from ``ChatSession.resume``. Since
  ``ChatSession.resume`` assigns ``self.messages`` *before* the
  config-restore block, a partial-failure resume (corrupted
  ``workstream_config`` row, model-registry mismatch on a saved
  alias, malformed ``temperature`` / ``max_tokens``) would leave
  the session with history but with default config. Pre-lift the
  interactive open handler called ``ws.session.resume`` directly
  and let exceptions propagate as 500. Restored that behaviour:
  ``mgr.open()`` now re-raises resume exceptions after rolling
  back the slot (``cleanup_ui`` + ``_remove_locked``), so the
  lifted handler returns 500 with a correlation id and the storage
  row stays available for a retry.
* **Bare ``except Exception`` documents intent.** A one-line
  rationale in the handler body explains why the catch is broad
  (no documented exception spec on ``adapter.build_session``;
  resume can propagate via the new contract above). Keeps a future
  contributor from narrowing it incorrectly.

Test scaffolding:

* ``tests/test_workstream_endpoints.py`` — fixture rebuilt to
  use ``make_open_handler`` + a minimal cfg with a lazy alias
  resolver so per-test ``@patch`` calls take effect. Added 5 new
  tests: already-loaded uses ws.name, alias resolution runs first,
  ``mgr.open`` is called (NOT ``mgr.create``), post-load callback
  fires with (request, ws) only on the load-from-storage path
  (not the already-loaded shortcut), post-load exception swallowed
  → 200.
* ``tests/test_coordinator_endpoints.py`` — fixture imports
  updated to ``make_open_handler``.
* ``tests/test_server_authz.py`` — ``TestOpenKindGate`` now expects
  404 (not pre-lift's 400) for cross-kind open attempts. Docstring
  explains the consolidation.

Two nit cleanups: dropped the unnecessary ``import secrets as
_secrets`` aliasing in the exception handler; refreshed the stale
``open_workstream`` reference in the ``AliasResolver`` doc-comment.

Lint + mypy clean. 4488 tests passing (was 4475; +13 new open
tests).

* fix(core): use cfg.audit_action_prefix for the per-kind noun in open's 500 error

PR #414 review caught the hardcoded ``"failed to open workstream"``
in ``make_open_handler``'s 500 path: coord callers got misleading
text (pre-lift coord said ``"failed to open coordinator"``).

The fix derives the noun from ``cfg.audit_action_prefix``
("workstream" interactive, "coordinator" coord) — a field both
production lifespans already construct, and which the previous
/review pipeline (q-5) flagged as dead config (set but read by
no factory). Reusing it here both fixes the wording AND gives
the field its first runtime reader.

Pinned by a new test
(``test_open_500_message_uses_kind_noun_from_cfg``) that wires a
coord-shaped cfg, forces ``mgr.open`` to raise, and asserts the
500 body contains ``"failed to open coordinator"`` + the
correlation id, without echoing the exception text.

Lint + mypy clean. 4489 tests passing (+1 new).
2026-04-26 00:44:14 -07:00
Patrick Buckley 3bdcf9870e fix(server): close review cleanup items from PRs #374 / #375 review (#376)
Third and final PR of the retrospective-review series.  Addresses the
remaining bug / perf / doc findings from the original multi-stage review
plus the three inline comments left on #374 and #375.

From the original review:

- bug-3: delete_workstream now nulls out parent_ws_id on every child
  row before dropping the target — previously, deleting a coordinator
  left orphaned parent_ws_id pointers and list_workstreams(parent_ws_id=
  <deleted>) kept returning ghost-parented rows.  Fix lives at the
  storage edge so both SQLite and PostgreSQL benefit without a schema
  migration.
- perf-1 / perf-2 / perf-3: new migration 041 drops the low-cardinality
  idx_workstreams_kind outright, rebuilds idx_workstreams_parent as a
  partial index (WHERE parent_ws_id IS NOT NULL) to halve its btree,
  and uses CREATE INDEX CONCURRENTLY on postgres so the rebuild
  doesn't take ACCESS EXCLUSIVE on populated tables.  Dialect-guarded;
  sqlite path is a straight partial CREATE INDEX.
- perf-5: _rebuild_children_from_storage bumps its limit sentinel to
  10_000 and logs a warning when the cap is hit instead of silently
  truncating the tail on every console cold-start.
- q-2: turnstone.core.memory.list_workstreams wrapper deleted (zero
  live callers; PR #374 kept it forward-compatible with the new
  kwargs as a stepping stone).
- q-5: migration 039's docstring now warns operators that downgrade
  drops parent_ws_id irreversibly and notes the 041 dependency.
- q-7: GET /v1/api/workstreams row shape now includes kind +
  parent_ws_id to match /v1/api/dashboard; the Pydantic
  WorkstreamInfo schema follows so SDK consumers see the same fields.

Inline review comments:

- #374 (copilot): console/server.py::coordinator_children now pushes
  user_id into the SQL filter for non-admin callers, so forged /
  migration-era rows with matching parent_ws_id but a different
  owner can't leak through.  Admins bypass the filter — they're
  expected to see the full subtree.
- #375 (copilot, delete handler): storage.get_workstream(ws_id) for
  the audit snapshot moved inside the try: block so a transient DB
  error surfaces through the endpoint's redacted 500 handler instead
  of an unhandled exception.
- #375 (copilot, _require_ws_access): added optional mgr= kwarg —
  when the workstream is live in the in-memory manager, trust its
  cached user_id instead of round-tripping storage.  In-memory-only
  handlers (approve / plan / cancel / command / close / events_sse /
  refresh-title / set-title) pass mgr= so they stay functional
  during transient DB outages and skip one query on the hot path.
  Storage-backed handlers (/delete, /open) omit mgr= and keep the
  storage path for persisted-but-not-loaded rows.

Tests:

- tests/test_workstream_kind.py adds regression tests for the cascade
  null-out on delete and the new user_id SQL filter.
- tests/test_workstream_endpoints.py updated so the title-handler
  tests exercise the in-memory fast path (MagicMock manager returning
  None falls through to storage; explicit ws.user_id set where the
  mock ws is used).

Lint (ruff), typecheck (strict mypy), pytest -m 'not live' all green
(4209 passing).
2026-04-17 22:42:46 -07:00
Patrick Buckley 294d6f5766 fix(server): close cross-tenant authz gaps on interactive-ws handlers (#375)
Second of three PRs addressing the retrospective review of the
turnstone-server interactive-kind feature.  The first (PR #374) put
the structural pieces in place — WorkstreamKind enum + user_id
kwarg on the storage protocol.  This PR uses them to close the
handler-level ownership gaps that shipped under the prior design.

- sec-1: approve / plan_feedback / cancel_generation / command now
  call _require_ws_access before touching the target UI.  Previously
  any authenticated user could resolve pending tool-approvals on
  another tenant's workstream — RCE-adjacent because the attacker
  could approve destructive operations the victim would have denied.
- sec-2: /v1/api/workstreams/{ws_id}/delete now gates on ownership
  AND writes a workstream.deleted audit event.  Previously any
  authenticated user could destroy any other tenant's workstream,
  conversations, and attachments in one call with no tamper-evident
  trail.
- sec-3: /v1/api/events (per-ws SSE) gates before _register_listener
  so non-owners can't subscribe to another tenant's message / tool /
  approval stream.
- sec-4 / sec-5: /v1/api/workstreams and /v1/api/dashboard filter
  to the caller's tenant view via a new _visible_workstreams helper;
  service-scoped tokens (cluster / routing proxy) keep the full view.
- sec-6: /v1/api/events/global requires service scope.  The global
  snapshot carries cross-tenant workstream inventory and was never
  intended for end-user browsers.
- sec-7: /v1/api/workstreams/{ws_id}/open verifies the caller is
  the stored owner (or holds service scope) before rehydrating.
  Returns 404 on mismatch — existence isn't enumerable by response
  code.
- sec-8 / sec-9: /workstreams/close, /refresh-title, /title all gate
  on ownership.  Cross-tenant close aborts the victim's running
  generation; cross-tenant rename is a phishing / denial-of-use
  vector in list / dashboard responses.
- sec-11: workstream.created / .deleted / .closed / .opened now
  land in the audit_events table with kind + parent_ws_id detail,
  so forensic review can reconstruct lifecycle even after the row
  is gone.
- q-4: new tests/test_server_authz.py covers every gate above via
  TestClient, plus the PR #1 HTTP-boundary kind-validation branches
  that had no regression coverage (coordinator / unknown-kind / 400,
  cross-tenant parent_ws_id / 403, non-interactive open / 400).
- q-3: test_workstream_kind.py now uses the conftest storage fixture
  so it runs against both SQLite and PostgreSQL under
  --storage-backend=postgresql, closing the sqlite↔postgres drift
  risk the prior review flagged.  Added storage-edge ValueError and
  user_id SQL filter tests alongside.

Tests, lint (ruff), typecheck (strict mypy) all green.  Stacked on
PR #374 — merges after that lands.
2026-04-17 22:22:03 -07:00
Patrick Buckley 5cbc4bc87c feat: bulk message insert for fork performance + endpoint tests (#322)
Add save_messages_bulk() to StorageBackend protocol and both backends.
Fork path now inserts all messages in a single transaction instead of
N individual save_message() calls — for a 200-message workstream this
goes from 200 connection/insert/commit cycles to 1.

FTS5 indexing is intentionally skipped for bulk fork data (historical
messages indexed on rebuild). Ordering preserved via auto-increment id
with a shared timestamp across all rows in the batch.

Also adds 22 endpoint tests covering the 6 new workstream management
endpoints (delete, open, title, refresh-title, list/update interface
settings) and 4 storage-level tests for the bulk insert path.
2026-04-06 02:54:34 -07:00